diff --git a/.github/scripts/code_ranker_comment.py b/.github/scripts/code_ranker_comment.py deleted file mode 100644 index 085cf6b5..00000000 --- a/.github/scripts/code_ranker_comment.py +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env python3 -"""Render a code-ranker PR comment (markdown) from `code-ranker check` JSON. - -Reads check.json in the CWD and env MODE (diff|review), RUN_URL, BASE_REF. - -- diff mode: check ran with --baseline, so the JSON is {"verdict", "violations"} - where violations are the NEW ones vs the baseline. -- review mode: no baseline existed, so the JSON is a plain violations array of - the current absolute state. - -Output goes to stdout; the workflow pipes it into the sticky comment. -""" -import json -import os - -MODE = os.environ.get("MODE", "review") -RUN_URL = os.environ.get("RUN_URL", "") -BASE_REF = os.environ.get("BASE_REF", "main") - -try: - with open("check.json") as fh: - raw = fh.read().strip() - data = json.loads(raw) if raw else [] -except (OSError, ValueError): - data = [] - -if isinstance(data, dict): # diff mode: {"verdict", "violations"} - verdict = data.get("verdict") - violations = data.get("violations", []) -else: # review mode: bare array - verdict = None - violations = data - -VERDICT_EMOJI = {"improved": "✅", "degraded": "❌", "neutral": "➖"} - - -def fmt(v): - loc = v.get("location") or "—" - if loc.startswith("{target}/"): - loc = loc[len("{target}/"):] # repo-relative reads cleaner in a comment - line = v.get("line") - where = f"{loc}:{line}" if line else loc - return f"`{v.get('rule', '?')}` · {where} — {v.get('message', '')}" - - -lines = ["## code-ranker"] - -if MODE == "diff": - emoji = VERDICT_EMOJI.get(verdict, "❔") - lines.append(f"**Verdict vs `{BASE_REF}`: {emoji} {verdict or 'unknown'}**") - if violations: - lines.append(f"\n**{len(violations)} new violation(s)** introduced by this PR:") - lines += [f"- {fmt(v)}" for v in violations[:20]] - if len(violations) > 20: - lines.append(f"- … and {len(violations) - 20} more") - else: - lines.append("\nNo new violations vs the baseline. 🎉") -else: # review - lines.append( - f"_No baseline on `{BASE_REF}` yet — **review** only, no diff. " - "Once this lands on the default branch, future PRs show a verdict._" - ) - if violations: - lines.append(f"\n**{len(violations)} violation(s)** in the current tree:") - lines += [f"- {fmt(v)}" for v in violations[:20]] - if len(violations) > 20: - lines.append(f"- … and {len(violations) - 20} more") - else: - lines.append("\nNo violations in the current tree. 🎉") - -if RUN_URL: - lines.append(f"\n📦 Full HTML report: see the **code-ranker-report** artifact on [this run]({RUN_URL}).") - -print("\n".join(lines)) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5fb1b14..4abd0951 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,7 +86,7 @@ jobs: - name: lychee (offline link check) uses: lycheeverse/lychee-action@v2 with: - args: --offline --no-progress --exclude-path languages/_overlay docs/**/*.md contrib/**/*.md languages/**/*.md AGENTS.md CLAUDE.md + args: --offline --no-progress --exclude-path plugins/_overlay docs/**/*.md contrib/**/*.md plugins/**/*.md AGENTS.md CLAUDE.md fail: true - name: markdownlint # Argless — globs and rules come from .markdownlint-cli2.jsonc. diff --git a/.github/workflows/code-ranker.yml b/.github/workflows/code-ranker.yml index 555c263d..5d356d23 100644 --- a/.github/workflows/code-ranker.yml +++ b/.github/workflows/code-ranker.yml @@ -1,11 +1,17 @@ +# Dogfood: run the PUBLIC code-ranker-ci scripts on this repo, but build +# code-ranker from these branch sources (not the released binary). name: code-ranker on: pull_request: - push: { branches: [main] } + push: jobs: - report: - uses: ffedoroff/code-ranker-ci/.github/workflows/report.yml@v1 + code-ranker: + uses: ffedoroff/code-ranker-ci/.github/workflows/report.yml@main + with: + install_from_source: true # build code-ranker from this checkout + do_check: false # advisory on our own PRs (don't red the build) permissions: id-token: write contents: read pull-requests: write + security-events: write diff --git a/.github/workflows/crates-io.yml b/.github/workflows/crates-io.yml index 140e0162..484f54bc 100644 --- a/.github/workflows/crates-io.yml +++ b/.github/workflows/crates-io.yml @@ -37,12 +37,12 @@ jobs: # right before publishing; cargo auto-detects a package-root README.md # when no `readme` field is set. `--allow-dirty` tolerates the copy. for d in crates/*/; do cp README.md "$d/README.md"; done - # Same trick for the `--doc` corpus: the languages/ tree lives at the + # Same trick for the `--doc` corpus: the plugins/ tree lives at the # repo root (outside the crate, so not in the published tarball by # default). Copy it into the CLI crate so `cargo publish` packs it and # `cargo install code-ranker` embeds the full corpus; build.rs prefers # this package-local copy over the repo-root tree. `--allow-dirty` covers it. - cp -R languages crates/code-ranker-cli/languages + cp -R plugins crates/code-ranker-cli/plugins for c in code-ranker-plugin-api code-ranker-graph code-ranker-plugins code-ranker-viewer code-ranker; do echo "==> publishing $c" attempt=0 diff --git a/.github/workflows/pr-report.yml b/.github/workflows/pr-report.yml deleted file mode 100644 index e71b5609..00000000 --- a/.github/workflows/pr-report.yml +++ /dev/null @@ -1,132 +0,0 @@ -# Posts a sticky code-ranker comment on every PR. If a baseline snapshot exists -# on the base branch (produced by this same workflow's push:main run), the -# comment is a DIFF with a verdict (improved/degraded/neutral) + new violations. -# Before any baseline exists (e.g. main hasn't run this yet), it falls back to a -# REVIEW summary of the current state — same graceful degradation as the GitLab -# / GitHub diff.example.yml. The job is advisory: a fork PR (no write token) or -# any fetch miss never fails it. -name: PR report - -on: - pull_request: - push: - branches: [main] # produces the baseline artifact future PRs diff against - -permissions: - contents: read - actions: read # download the base-branch baseline artifact - pull-requests: write # post/update the sticky comment - security-events: write # upload the SARIF to code scanning - -concurrency: - group: pr-report-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - report: - runs-on: ubuntu-22.04 - continue-on-error: true # advisory — never blocks the PR - steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - - name: Warm the cargo cache (rust plugin runs `cargo metadata --offline`) - # No prior build/test in this job, so $CARGO_HOME lacks the full dep - # graph and offline metadata resolution fails (e.g. on a platform-only - # crate like android_system_properties). Fetch fills the cache first. - run: cargo fetch --locked - - - name: Analyze -> JSON + HTML + SARIF snapshot - env: - PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: | - H="$(git rev-parse --short=12 HEAD)" - BR="${GITHUB_HEAD_REF:-$GITHUB_REF_NAME}" - echo "H=$H" >> "$GITHUB_ENV" - # One analysis pass emits all three artifacts. The SARIF carries the - # current rule violations (with stable partialFingerprints) for code - # scanning; GitHub does its own cross-run dedup, so it is absolute, not a - # diff. - cargo run -q -p code-ranker -- report . \ - --git.branch="$BR" \ - --git.commit="${PR_HEAD_SHA:-$GITHUB_SHA}" \ - --git.dirty-files=0 \ - --git.origin="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}" \ - --output.json.path="code-ranker-${H}.json" \ - --output.html.path="code-ranker-${H}.html" \ - --output.sarif.path="code-ranker-${H}.sarif" - - # Inline alerts in the PR diff + the Security → Code scanning tab. Same-repo - # only: a fork PR gets a read-only token and cannot upload (the job is - # advisory, so a skip is fine). - - name: Upload SARIF to code scanning - if: always() && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository) - uses: github/codeql-action/upload-sarif@v4 - with: - sarif_file: code-ranker-${{ env.H }}.sarif - - # Keep the snapshot as an artifact. On main this becomes the baseline that - # future PRs download; on a PR it's the downloadable full HTML report. - - name: Upload snapshot - uses: actions/upload-artifact@v7 - with: - name: code-ranker-report - path: | - code-ranker-*.json - code-ranker-*.html - code-ranker-*.sarif - retention-days: 30 - - - name: Fetch baseline from the base branch (best-effort) - if: github.event_name == 'pull_request' - env: - GH_TOKEN: ${{ github.token }} - run: | - # Latest successful run of THIS workflow on the base branch → its JSON. - # Any miss → no baseline → review summary; never fails the job. - set +e - RID="$(gh run list --branch "$GITHUB_BASE_REF" --workflow "$GITHUB_WORKFLOW" \ - --status success --limit 1 --json databaseId --jq '.[0].databaseId')" - if [ -n "$RID" ]; then - gh run download "$RID" --name code-ranker-report --dir base - fi - BASE_JSON="$(ls base/code-ranker-*.json 2>/dev/null | head -n1)" - echo "BASE_JSON=$BASE_JSON" >> "$GITHUB_ENV" - - # `::error` workflow commands → inline annotations on the PR "Files changed". - # Same diff/review logic as the comment (only NEW violations once a baseline - # exists). `|| true`: check exits non-zero on a breach, but this is advisory. - - name: Annotate PR (inline, github format) - if: github.event_name == 'pull_request' - run: | - if [ -n "${BASE_JSON:-}" ]; then - cargo run -q -p code-ranker -- check "code-ranker-${H}.json" --baseline "$BASE_JSON" \ - --output-format github || true - else - cargo run -q -p code-ranker -- check "code-ranker-${H}.json" --output-format github || true - fi - - - name: Build comment (diff if baseline, else review) - if: github.event_name == 'pull_request' - run: | - RUN_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" - if [ -n "${BASE_JSON:-}" ]; then - # DIFF: relative gate → verdict + only the NEW violations vs baseline. - cargo run -q -p code-ranker -- check "code-ranker-${H}.json" --baseline "$BASE_JSON" \ - --output-format json > check.json || true - MODE=diff - else - # REVIEW: no baseline yet → absolute current-state violations. - cargo run -q -p code-ranker -- check "code-ranker-${H}.json" --output-format json > check.json || true - MODE=review - fi - MODE="$MODE" RUN_URL="$RUN_URL" BASE_REF="${GITHUB_BASE_REF}" \ - python3 .github/scripts/code_ranker_comment.py > comment.md - cat comment.md - - - name: Sticky comment - if: github.event_name == 'pull_request' - uses: marocchino/sticky-pull-request-comment@v3 - with: - header: code-ranker - path: comment.md diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index c61419be..07026e98 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -18,13 +18,13 @@ "globs": [ "docs/**/*.md", "contrib/**/*.md", - "languages/**/*.md", + "plugins/**/*.md", "AGENTS.md", "CLAUDE.md" ], "ignores": [ "node_modules/**", "target/**", - "languages/_overlay/**" + "plugins/_overlay/**" ] } diff --git a/Cargo.lock b/Cargo.lock index 1a6e94ad..60fb10c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -268,7 +268,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "code-ranker" -version = "4.0.0" +version = "5.0.0" dependencies = [ "anyhow", "chrono", @@ -286,7 +286,7 @@ dependencies = [ [[package]] name = "code-ranker-graph" -version = "4.0.0" +version = "5.0.0" dependencies = [ "cel", "chrono", @@ -298,7 +298,7 @@ dependencies = [ [[package]] name = "code-ranker-plugin-api" -version = "4.0.0" +version = "5.0.0" dependencies = [ "anyhow", "chrono", @@ -309,7 +309,7 @@ dependencies = [ [[package]] name = "code-ranker-plugins" -version = "4.0.0" +version = "5.0.0" dependencies = [ "anyhow", "cargo_metadata", @@ -336,7 +336,7 @@ dependencies = [ [[package]] name = "code-ranker-viewer" -version = "4.0.0" +version = "5.0.0" dependencies = [ "anyhow", "code-ranker-graph", diff --git a/Cargo.toml b/Cargo.toml index 7cd9c54b..e33ad91a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "3" [workspace.package] -version = "4.0.0" +version = "5.0.0" edition = "2024" rust-version = "1.88" license = "Apache-2.0" @@ -12,10 +12,10 @@ keywords = ["dependency-graph", "coupling", "refactoring", "code-quality", "stat categories = ["development-tools", "command-line-utilities"] [workspace.dependencies] -code-ranker-graph = { path = "crates/code-ranker-graph", version = "4.0.0" } -code-ranker-plugin-api = { path = "crates/code-ranker-plugin-api", version = "4.0.0" } -code-ranker-plugins = { path = "crates/code-ranker-plugins", version = "4.0.0" } -code-ranker-viewer = { path = "crates/code-ranker-viewer", version = "4.0.0" } +code-ranker-graph = { path = "crates/code-ranker-graph", version = "5.0.0" } +code-ranker-plugin-api = { path = "crates/code-ranker-plugin-api", version = "5.0.0" } +code-ranker-plugins = { path = "crates/code-ranker-plugins", version = "5.0.0" } +code-ranker-viewer = { path = "crates/code-ranker-viewer", version = "5.0.0" } anyhow = "1.0" cel = "0.13" diff --git a/Makefile b/Makefile index 765823f1..79c0e7ff 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ fmt-check: cargo fmt --all --check lint-md: - lychee --offline --no-progress --exclude-path languages/_overlay 'docs/**/*.md' 'contrib/**/*.md' 'languages/**/*.md' 'AGENTS.md' 'CLAUDE.md' + lychee --offline --no-progress --exclude-path plugins/_overlay 'docs/**/*.md' 'contrib/**/*.md' 'plugins/**/*.md' 'AGENTS.md' 'CLAUDE.md' npx --yes markdownlint-cli2 # Unused-dependency check (fast, stable toolchain). FAILS the build on any unused @@ -86,6 +86,24 @@ clean: # make publish (phase 2) is the single Release button: after Verify is green it # dispatches publish.yml to release everywhere # (crates.io / PyPI / Docker / GitHub Release + npm). +# +# GitHub-ONLY prerelease (an alpha for testing — GitHub Release + binaries, and +# NOTHING on any registry). The registries are SEPARATE workflows that run only +# from publish.yml, so do NOT use `make publish` here — dispatch release.yml +# directly and it publishes a GitHub Release + binaries and nothing else. The one +# registry job baked into release.yml is npm; set `publish-prereleases = false` in +# dist-workspace.toml and it (and any other publish-job) is SKIPPED for a +# prerelease tag. Cut it from a THROWAWAY branch so the alpha version bump never +# lands on main: +# git checkout -b release/vX.Y.Z-alpha +# # in dist-workspace.toml: publish-prereleases = false +# make bump VERSION=X.Y.Z-alpha && git commit -am 'release vX.Y.Z-alpha' +# git push -u origin release/vX.Y.Z-alpha +# git tag -a vX.Y.Z-alpha -m vX.Y.Z-alpha && git push origin vX.Y.Z-alpha +# gh workflow run release.yml --ref release/vX.Y.Z-alpha -f tag=vX.Y.Z-alpha +# Verify goes RED on the PyPI job for a non-PEP-440 suffix like `-pre-alpha` — +# expected and harmless: a direct release.yml dispatch is not gated by Verify and +# never runs the PyPI/crates/Docker workflows. Delete the branch + tag when done. bump: @if [ -z "$(VERSION)" ]; then echo "usage: make bump VERSION=0.1.0-alpha.12"; exit 1; fi @@ -96,13 +114,13 @@ bump: echo "bumping $$CURRENT -> $(VERSION)"; \ LC_ALL=C sed -i '' "s/$$CURRENT/$(VERSION)/g" Cargo.toml README.md; \ RE=$$(printf '%s' "$$CURRENT" | sed 's/[.]/\\./g'); \ - for f in $$(grep -rlF "$$CURRENT" docs AGENTS.md CLAUDE.md languages 2>/dev/null || true); do \ + for f in $$(grep -rlF "$$CURRENT" docs AGENTS.md CLAUDE.md plugins 2>/dev/null || true); do \ LC_ALL=C sed -i '' -E "/code-ranker|--version/ s/$$RE/$(VERSION)/g" "$$f" && echo " ✓ fixed doc version refs in $$f"; \ done cargo build --workspace @echo @echo " remaining stale version mentions in docs (auto-fix only touches code-ranker/--version lines):" - @hits=$$(grep -rnE -- '--version[ =]+v?[0-9]+\.[0-9]+\.[0-9]+|[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc)|code-ranker[@:" ]+v?[0-9]+\.[0-9]+\.[0-9]+' docs README.md AGENTS.md CLAUDE.md languages 2>/dev/null | grep -vF "$(VERSION)" || true); \ + @hits=$$(grep -rnE -- '--version[ =]+v?[0-9]+\.[0-9]+\.[0-9]+|[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc)|code-ranker[@:" ]+v?[0-9]+\.[0-9]+\.[0-9]+' docs README.md AGENTS.md CLAUDE.md plugins 2>/dev/null | grep -vF "$(VERSION)" || true); \ if [ -n "$$hits" ]; then echo "$$hits" | sed 's/^/ /'; echo " ^ not at $(VERSION) — review (bare numbers off code-ranker/--version lines are left alone on purpose)"; else echo " (none — all at $(VERSION))"; fi @echo @echo " ✓ bumped to $(VERSION) — review and commit:" diff --git a/README.md b/README.md index 88c292cb..8ff5f1a0 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Structural-analysis tool for **Rust** (production-ready) plus **Python, TypeScri **👉 Map your codebase's worst structural problems in 30 seconds — [jump to the Rust quick start](#rust-quick-start) and run it on your repo now.** -**Status:** 4.0.0 — the Rust analyzer is production-ready; the other languages are beta, so their output shapes may still change. +**Status:** 5.0.0 — the Rust analyzer is production-ready; the other languages are beta, so their output shapes may still change. ## Rust quick start @@ -35,14 +35,14 @@ code-ranker always runs **entirely on your machine**. It makes **no network call ## AI agents friendly -**Hand your codebase to an AI agent and let it fix the worst spot.** code-ranker is built to feed work straight to an AI coding agent (Claude Code, Cursor, …). Run **`code-ranker docs ai`** in your repo — it prints a short, offline playbook (no network) that teaches the agent which two metrics matter (dependency cycles `ADP`, coupling `HK`) and the exact fix loop (scorecard → snapshot → fix → re-check → before/after report), tailored to your project's language. +**Hand your codebase to an AI agent and let it fix the worst spot.** code-ranker is built to feed work straight to an AI coding agent (Claude Code, Cursor, …). Run **`code-ranker docs ai`** in your repo (e.g. `code-ranker docs rust ai`) — it prints a short, offline playbook (no network) that teaches the agent which two metrics matter (dependency cycles `ADP`, coupling `HK`) and the exact fix loop (scorecard → snapshot → fix → re-check → before/after report), for your project's language. Then just ask, e.g.: -- *"Run `code-ranker docs ai` and follow it: find the worst dependency cycle in this project and propose a refactor that breaks it — show me the plan before changing code."* -- *"Run `code-ranker docs ai` for the playbook, then find the most complex / highest-HK file and analyze how to split it; explain what the split buys for me (lower coupling, smaller blast radius). Take a **before report**, apply the split, take an **after report**, and show me the **HTML diff**."* +- *"Run `code-ranker docs rust ai` and follow it: find the worst dependency cycle in this project and propose a refactor that breaks it — show me the plan before changing code."* +- *"Run `code-ranker docs rust ai` for the playbook, then find the most complex / highest-HK file and analyze how to split it; explain what the split buys for me (lower coupling, smaller blast radius). Take a **before report**, apply the split, take an **after report**, and show me the **HTML diff**."* -The agent drives the CLI itself — `code-ranker docs ai` spells out the commands and the loop, so no glue is needed. (Prefer a file in context? The same playbook lives at [docs/ai-skill.md](docs/ai-skill.md).) +The agent drives the CLI itself — `code-ranker docs ai` spells out the commands and the loop, so no glue is needed. (Prefer a file in context? The same playbook lives at [docs/ai-skill.md](docs/ai-skill.md).) ## What it finds @@ -124,7 +124,7 @@ Built-in plugins for all nine supported languages (`rust` uses cargo + syn; Rust - [PRD](docs/PRD.md) — product requirements - [DESIGN](docs/DESIGN.md) — technical design - [Why structure matters](docs/why-structure-matters.md) — the empirical evidence (studies, books, statistics) behind the signals code-ranker measures -- [Principles corpus](languages/) — Rust / Python / TypeScript principle catalogues used by the prompt generator +- [Principles corpus](plugins/) — Rust / Python / TypeScript principle catalogues used by the prompt generator ## Try it now diff --git a/code-ranker.toml b/code-ranker.toml index ce7a3023..0fac132f 100644 --- a/code-ranker.toml +++ b/code-ranker.toml @@ -1,4 +1,4 @@ -version = "4.0" +version = "5.0" # code-ranker.toml — project-level configuration for code-ranker. # Discovery order: --config PATH > ./code-ranker.toml > /code-ranker.toml > # Cargo.toml [workspace.metadata.code-ranker]. CLI flags always win over the file. @@ -8,8 +8,12 @@ version = "4.0" # this project overrides — every unset key inherits the built-in default (e.g. # `[ignore]` tests/gitignore on, `[rules.cycles]` strict). CLI flags still win. -# Default plugin; overridden by --plugin. `auto` (or unset) → marker detection. -plugin = "rust" +# Languages are auto-detected (no `[plugins] enabled`), so the repo gates its Rust +# product, its Markdown docs, and any other language present. Exclude dev tooling +# from that self-analysis: `contrib/` holds one-off helper scripts (the prompt-eval +# harness) and notes, not product code. +[plugins.base.ignore] +paths = ["contrib/**"] # ── Output artifacts (`report`) ────────────────────────────────────────────── # Override only the path template (the built-in default is @@ -29,34 +33,39 @@ path = ".code-ranker/{project-dir}.html" # # `hk` (`sloc × (fan_in × fan_out)²`) is a *regression ratchet*, not an active # shortlist: the worst module is the `LanguagePlugin` trait -# `code-ranker-plugin-api/src/plugin.rs` (~0.30M, fan_in 12 × fan_out 6) — an -# irreducible hub whose `fan_in` grows with each new language, so HK climbs -# quadratically (+1 language ≈ 0.35M, +2 ≈ 0.40M). Reducing it would be -# metric-gaming, so it is accepted by design. The orchestrator -# `code-ranker-cli/src/pipeline.rs` (~0.18M, high fan_out) is likewise accepted. +# `code-ranker-plugin-api/src/plugin.rs` (~0.44M, fan_in 14 × fan_out 6) — an +# irreducible hub whose `fan_in` grows with each new language and each subsystem +# that talks to the trait, so HK climbs quadratically (+1 in-edge ≈ 0.51M). The +# v5.0 multi-language work raised it from ~0.33M (fan_in 12): the per-language +# effective-config injection added one in-edge, and splitting the CLI plugin facade +# into method-dispatch (`plugin/mod.rs`) + selection-policy (`plugin/resolve.rs`) +# added another (both now import `PluginInput`). Reducing the trait's own coupling +# would be metric-gaming, so it is accepted by design. The orchestrator +# `code-ranker-cli/src/pipeline.rs` (~0.27M, high fan_out) is likewise accepted. # Because plugin.rs is BOTH the largest and accepted, no ceiling can flag a # smaller file without also failing it — so this gate is green by design and only # trips on a genuinely new/worse hub. (`builtin.rs` ~0.25M, sloc 248, is the one # real split candidate, but it sits below plugin.rs — chase it via the scorecard -# ranking, not the gate.) The 350K ceiling sits just above plugin.rs with ~1 +# ranking, not the gate.) The 500K ceiling sits just above plugin.rs with ~1 # language of growth headroom; when a new language legitimately pushes plugin.rs -# past it, bump deliberately (or split a genuinely-too-big module, as the former -# ~1.15M `config.rs` hub was split into the `config/` concern facade). -[rules.thresholds.file] -hk = 350000 +# past it, bump deliberately (or split a genuinely-too-big module — as the former +# ~1.15M `config.rs` hub became the `config/` facade, and the v5.0 multi-language +# `plugin/mod.rs` facade was split into `mod.rs` + `resolve.rs`). +[plugins.base.rules.thresholds.file] +hk = 500000 cyclomatic = 100 cognitive = 70 sloc = 400 # ── Custom checks (config-only linters) ────────────────────────────────────── # Reusable CEL helper: is this file already a dedicated test file? -[rules.defs] +[plugins.base.rules.defs] is_test_file = 'name.endsWith("_test.rs") || name.endsWith("_tests.rs") || path.contains("/tests/")' # Flag production files with a large inline `#[cfg(test)]` block — split the tests # into a sibling `{stem}_test.rs` so they don't bloat the production file's # size/coupling metrics. `tloc` is the file's inline test lines. -[rules.checks.inline_tests_too_large] +[plugins.base.rules.checks.inline_tests_too_large] when = "tloc > 100 && !is_test_file" message = "{tloc} inline test lines — split them into a sibling `{stem}_test.rs`" group = "TST" diff --git a/contrib/prompt-eval-metrics.py b/contrib/prompt-eval-metrics.py index a5185add..ac9f951f 100755 --- a/contrib/prompt-eval-metrics.py +++ b/contrib/prompt-eval-metrics.py @@ -159,7 +159,7 @@ def parse(x): # adherence m["used_generated_prompt"] = 1 if any( - ("--output.prompt" in c) or ("--prompt " in c) or ("--prompt=" in c) for _, c in cmds + ("--prompt " in c) or ("--prompt=" in c) for _, c in cmds ) else 0 framing = [] if any("--focus cycle" in c for _, c in cmds): @@ -221,10 +221,15 @@ def node_metric(path, key): the snapshot's node_attributes schema (`lower_better` / `higher_better` / None).""" with open(path) as fh: d = json.load(fh) - files = (d.get("graphs") or {}).get("files") or {} - vals = [n[key] for n in files.get("nodes") or [] - if not n.get("external") and isinstance(n.get(key), (int, float))] - direction = ((files.get("node_attributes") or {}).get(key) or {}).get("direction") + # The snapshot nests graphs under languages., so aggregate the metric + # across every language's files graph (a run can cover several at once). + vals, direction = [], None + for lang in (d.get("languages") or {}).values(): + files = (lang.get("graphs") or {}).get("files") or {} + vals += [n[key] for n in files.get("nodes") or [] + if not n.get("external") and isinstance(n.get(key), (int, float))] + if direction is None: + direction = ((files.get("node_attributes") or {}).get(key) or {}).get("direction") return vals, direction diff --git a/contrib/prompting-self-improve.md b/contrib/prompting-self-improve.md index 6c6eb364..68191afd 100644 --- a/contrib/prompting-self-improve.md +++ b/contrib/prompting-self-improve.md @@ -67,15 +67,15 @@ of these and rebuild (see Setup) — all are baked into the binary: - **scaffolding** (intro / doc-note / task / focus prose) — `crates/code-ranker-graph/metrics/prompt.md`. - **the full reference doc** the agent reads via `docs ` — - `languages//.md` (e.g. `ADP.md`), and the offline entry point - `languages/base/AI.md` (`docs ai`). + `plugins//.md` (e.g. `ADP.md`), and the offline entry point + `plugins/base/AI.md` (`docs ai`). Change the **smallest** lever that fixes the observed failure. **Respect the base / per-language boundary.** Language-specific content (Rust -`pub(in …)`, a Python import idiom, …) belongs ONLY in `languages//` (its +`pub(in …)`, a Python import idiom, …) belongs ONLY in `plugins//` (its `.md` doc) or the per-language `config.toml` prompt override — **never** in the -language-neutral `languages/base/AI.md` or the neutral `defaults.toml` prompt. When a +language-neutral `plugins/base/AI.md` or the neutral `defaults.toml` prompt. When a cheaper tier fails for want of a language-specific remedy, the base lever stays generic ("read `docs ` — it has the cause and smallest fix for *your* language") and the specifics live in the per-language doc it points at. Putting a Rust example in @@ -156,7 +156,7 @@ nothing eval-related is left in `PROJECT`. coaching. 3. **BEFORE.** `code-ranker report . --output.html.path=$RUN/before.html --output.json.path=$RUN/before.json`. 4. **Save the focused prompt** (orchestrator, for the record): - `code-ranker report . --output.prompt.path=$RUN/prompt.md --focus --top 1` + `code-ranker report . --prompt > $RUN/prompt.md` — captures the exact fix-prompt this run used into `$RUN/prompt.md`, so prompt ↔ behaviour stays correlatable across models. 5. **Fix** (agent). Ask the agent to fix the single worst (`--top 1`) cycle and **let it @@ -363,7 +363,7 @@ Columns, grouped by objective (most are extractable from the run's artifacts; th | `read_doc_ai` / `read_doc_focus` | clarity | transcript | 1/0 — read `docs ai` / `docs ` | | `doc_reread` | clarity | transcript | ↓ times a doc was read more than once (a re-read signals the prompt/doc wasn't clear the first time) | | `planned_before_edit` | clarity | transcript | 1/0 — proposed a plan before editing | -| `used_generated_prompt` | adherence | transcript | 1/0 — actually fetched the tool's fix-prompt (`--output.prompt` / `--prompt`) vs improvising | +| `used_generated_prompt` | adherence | transcript | 1/0 — actually fetched the tool's fix-prompt (`--prompt`) vs improvising | | `focus_framing` | adherence | transcript | which lens the agent chose — `ADP` (principle) or `cycle` (metric); reveals how it read the task | | `first_edit_turn` | clarity | transcript | tool-call index of the first `Edit`/`Write` — very high = lots of exploration before acting (thoroughness, or an unclear prompt) | | `clarifying_qs` | clarity | transcript | ↓ questions the prompt should have pre-answered | diff --git a/contrib/unit-tests.md b/contrib/unit-tests.md index 671db349..0842d6e1 100644 --- a/contrib/unit-tests.md +++ b/contrib/unit-tests.md @@ -17,7 +17,8 @@ This is the project's line of defense for correctness. Every test is a synchrono Every test must pass all three: -1. **Does it verify deterministic logic?** Parsing, rule evaluation, plugin resolution, +1. **Does it verify deterministic logic?** Parsing, rule evaluation, plugin resolution + (which languages a workspace yields), name templating — all deterministic, all testable. 2. **Is it atomic and fast?** One `#[test]` = one behavior. No `sleep`, no `timeout`. The whole suite runs in well under 5 seconds. @@ -46,8 +47,8 @@ Every test must pass all three: | Area | What to cover | Where | |---|---|---| | Config parsing | `--cycle-rule KIND=on\|off`, `--threshold SCOPE.METRIC=N`, defaults, rejection of bad input | `code-ranker-cli/src/config.rs` | -| Rule evaluation | `check_violations` (cycles + thresholds); `apply_cycle_rules` strips disabled kinds | `code-ranker-cli/src/config.rs` | -| Plugin resolution | `resolve_plugin` precedence; `detect_plugin` markers / ambiguity / none | `code-ranker-cli/src/main.rs` | +| Rule evaluation | `check_violations_all(languages, rules)` (cycles + thresholds across every language); `apply_cycle_rules` strips disabled kinds | `code-ranker-cli/src/config.rs` | +| Plugin resolution | `resolve_plugins` precedence; `detect_all` markers / multi-detect / none | `code-ranker-cli/src/main.rs` | | Name templating | `render_name` — `{project-dir}` slug, `{ts}` stamp, `{git-hash}` / `{git-hash-N}`; `[output]` name resolution | `code-ranker-cli/src/main.rs`, `config.rs` | | Snapshot & graph types | serde round-trip of the snapshot (the public artifact); builder / projection invariants; cycle and HK annotation | `code-ranker-core/src/*` | | Graph extraction | module / file graph shape on small in-source inputs | `code-ranker-syn/src/*` | @@ -75,12 +76,13 @@ applies: ```rust // BAD — only checks it didn't error -let v = check_violations(&graphs, &rules); +let v = check_violations_all(&languages, &rules); assert!(!v.is_empty()); -// GOOD — checks the count, which graph, the message, AND that the -// in-budget node did NOT contribute a violation +// GOOD — checks the count, which language + graph, the message, AND that +// the in-budget node did NOT contribute a violation assert_eq!(v.len(), 1, "only the over-budget node violates"); +assert_eq!(v[0].language, "rust"); assert_eq!(v[0].graph, "functions"); assert!(v[0].message.contains("cognitive"), "got {:?}", v[0].message); ``` @@ -118,7 +120,7 @@ fn node_with_cognitive(id: &str, cognitive: f64) -> Node { /* … */ } ```rust let d = tempfile::tempdir().unwrap(); std::fs::write(d.path().join("Cargo.toml"), "").unwrap(); -assert_eq!(detect_plugin(d.path()).unwrap(), "rust"); +assert_eq!(detect_all(d.path()).unwrap(), ["rust"]); ``` ## Naming @@ -130,8 +132,9 @@ parse_on_off_accepts_on_off_true_false cycle_rules_default_test_embed_off_others_on check_reports_enabled_cycle_group apply_cycle_rules_strips_disabled_kind -detect_plugin_errors_on_ambiguous_or_empty -resolve_plugin_precedence_explicit_then_config_then_auto +detect_all_returns_every_matching_language +detect_all_errors_on_zero_detect +resolve_plugins_precedence_explicit_then_config_then_auto ``` ## Organization diff --git a/crates/code-ranker-cli/build.rs b/crates/code-ranker-cli/build.rs index d6ff3f83..fda3b520 100644 --- a/crates/code-ranker-cli/build.rs +++ b/crates/code-ranker-cli/build.rs @@ -1,13 +1,13 @@ -//! Embed the `languages/` principle/metric doc corpus into the binary. +//! Embed the `plugins/` principle/metric doc corpus into the binary. //! -//! Walks the repo-root `languages/` tree and generates `$OUT_DIR/corpus.rs` — a +//! Walks the repo-root `plugins/` tree and generates `$OUT_DIR/corpus.rs` — a //! `CORPUS: &[(rel_path, contents)]` slice built from `include_str!`, so the tool //! can serve a principle's Markdown (e.g. `--doc HK`) from the binary itself with //! no filesystem at runtime. Dependency-free (no `include_dir` crate). //! -//! The single source of truth lives at the repo root (`../../languages`), OUTSIDE +//! The single source of truth lives at the repo root (`../../plugins`), OUTSIDE //! this crate. So that `cargo install code-ranker` from crates.io still embeds the -//! corpus, the publish workflow copies that tree into a package-local `languages/` +//! corpus, the publish workflow copies that tree into a package-local `plugins/` //! right before `cargo publish` (mirroring the per-crate README copy) — and this //! build script prefers that package-local copy, falling back to the repo-root tree //! for workspace/dev builds. If NEITHER exists (an unexpected isolated build) the @@ -24,8 +24,8 @@ fn main() { // Prefer the package-local copy (present in the published tarball), else the // repo-root tree (workspace/dev builds). Best-effort: a missing corpus is NOT // an error — it must never break `cargo publish`/`cargo install`. See module docs. - let local = Path::new(&manifest).join("languages"); - let root = Path::new(&manifest).join("../../languages"); + let local = Path::new(&manifest).join("plugins"); + let root = Path::new(&manifest).join("../../plugins"); let resolved = local.canonicalize().or_else(|_| root.canonicalize()); match resolved { Ok(corpus) => { @@ -36,7 +36,7 @@ fn main() { } Err(_) => { println!( - "cargo:warning=languages/ corpus not found at ./languages or ../../languages \ + "cargo:warning=plugins/ corpus not found at ./plugins or ../../plugins \ — embedding an empty corpus; `--doc` will report \"not embedded\". Published \ builds carry a package-local copy (see crates-io.yml); workspace builds use the \ repo-root tree." diff --git a/crates/code-ranker-cli/src/analyze.rs b/crates/code-ranker-cli/src/analyze.rs index 5938899c..27325961 100644 --- a/crates/code-ranker-cli/src/analyze.rs +++ b/crates/code-ranker-cli/src/analyze.rs @@ -37,15 +37,15 @@ pub(crate) fn analyze_input( /// Snapshot input: read the embedded snapshot and evaluate the current rules /// against it — no source tree or toolchain required. Analysis-only flags -/// (`--plugin` / `--ignore`) are rejected because there is nothing to analyze. +/// (`--plugins` / `--ignore`) are rejected because there is nothing to analyze. fn analyze_from_snapshot( args: &AnalyzeArgs, cycle_rules: &[String], thresholds: &[String], ) -> Result { - if args.plugin.is_some() { + if !args.plugins.is_empty() { anyhow::bail!( - "--plugin does not apply to a snapshot input ({}): there is nothing to analyze", + "--plugins does not apply to a snapshot input ({}): there is nothing to analyze", args.input.display() ); } @@ -62,17 +62,29 @@ fn analyze_from_snapshot( .context("configuration error")?; let cfg = loaded.config; - let mut graphs = snapshot.graphs.clone(); - if let Some(level) = graphs.get_mut("files") { - config::apply_cycle_rules(&mut level.cycles, &mut level.nodes, &cfg.rules.cycles); + // Resolve each snapshot language's effective rules (`[plugins.base]` ⊕ + // `[plugins.]`), then apply cycle rules and gate per language. + let mut rules_by_lang: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for lang in snapshot.languages.keys() { + rules_by_lang.insert(lang.clone(), cfg.language_config(lang)?.rules); } - let violations = config::check_violations(&graphs, &cfg.rules); + let mut languages = snapshot.languages.clone(); + for (lang, ls) in languages.iter_mut() { + if let Some(level) = ls.graphs.get_mut("files") { + let cycles = rules_by_lang + .get(lang) + .map(|r| r.cycles) + .unwrap_or_default(); + config::apply_cycle_rules(&mut level.cycles, &mut level.nodes, &cycles); + } + } + let violations = config::check_violations_all(&languages, &rules_by_lang); Ok(Analyzed { snapshot, violations, - cycles: cfg.rules.cycles, - rules: cfg.rules, + rules_by_lang, output: cfg.output, }) } @@ -136,84 +148,5 @@ fn ensure_schema(version: &str, path: &Path) -> Result<()> { } #[cfg(test)] -mod tests { - use super::*; - use std::collections::BTreeMap; - use std::fs; - - fn mk_snap() -> Snapshot { - Snapshot::new( - "cmd".into(), - "ws".into(), - "tgt".into(), - "rust".into(), - None, - BTreeMap::new(), - BTreeMap::new(), - None, - Vec::new(), - BTreeMap::new(), - Vec::new(), - Default::default(), - ) - } - - #[test] - fn viewer_embeds_snapshot_inline_and_round_trips() { - let snap = mk_snap(); - // review: current = snapshot, baseline = null - let html = code_ranker_viewer::render_html_viewer(None, Some(&snap)); - assert!( - html.contains(r#""#), - "baseline is null in review mode" - ); - let back = code_ranker_viewer::extract_embedded_snapshot(&html, "cs-current") - .expect("cs-current present") - .unwrap(); - assert_eq!(back.plugin, "rust", "round-trips through embed/extract"); - assert!( - code_ranker_viewer::extract_embedded_snapshot(&html, "cs-baseline").is_none(), - "null baseline extracts to None" - ); - } - - #[test] - fn load_snapshot_any_reads_json_and_html() { - let snap = mk_snap(); - let d = tempfile::tempdir().unwrap(); - - let jp = d.path().join("s.json"); - fs::write(&jp, serde_json::to_string(&snap).unwrap()).unwrap(); - assert_eq!(load_snapshot_any(&jp).unwrap().plugin, "rust", "from .json"); - - let hp = d.path().join("r.html"); - fs::write( - &hp, - code_ranker_viewer::render_html_viewer(None, Some(&snap)), - ) - .unwrap(); - assert_eq!( - load_snapshot_any(&hp).unwrap().plugin, - "rust", - "from embedded .html" - ); - } - - #[test] - fn load_snapshot_rejects_schema_version_mismatch() { - let d = tempfile::tempdir().unwrap(); - let jp = d.path().join("old.json"); - // A snapshot tagged with a different schema version must be rejected - // with a structured error (not silently mis-parsed). - let mut v = serde_json::to_value(mk_snap()).unwrap(); - v["schema_version"] = serde_json::Value::String("1".into()); - fs::write(&jp, serde_json::to_string(&v).unwrap()).unwrap(); - let err = format!("{:#}", load_snapshot_any(&jp).unwrap_err()); - assert!(err.contains("schema_version"), "schema error: {err}"); - assert!(err.contains("\"1\""), "names the offending version: {err}"); - } -} +#[path = "analyze_test.rs"] +mod tests; diff --git a/crates/code-ranker-cli/src/analyze_test.rs b/crates/code-ranker-cli/src/analyze_test.rs new file mode 100644 index 00000000..d4a040c1 --- /dev/null +++ b/crates/code-ranker-cli/src/analyze_test.rs @@ -0,0 +1,209 @@ +use super::*; +use std::collections::BTreeMap; +use std::fs; + +fn mk_snap() -> Snapshot { + use code_ranker_graph::snapshot::{LanguageSnapshot, SnapshotInit}; + use code_ranker_plugin_api::PromptTemplate; + let mut languages = BTreeMap::new(); + languages.insert( + "rust".to_string(), + LanguageSnapshot { + graphs: BTreeMap::new(), + principles: Vec::new(), + prompt: PromptTemplate::default(), + }, + ); + Snapshot::new(SnapshotInit { + command: "cmd".into(), + workspace: "ws".into(), + target: "tgt".into(), + plugins: vec!["rust".to_string()], + config_file: None, + versions: BTreeMap::new(), + roots: BTreeMap::new(), + git: None, + timings: Vec::new(), + languages, + }) +} + +/// A `rust` snapshot whose `files` level holds one mutual cycle. +fn mk_snap_with_cycle() -> Snapshot { + use code_ranker_graph::level_graph::{CycleGroup, LevelGraph}; + use code_ranker_graph::snapshot::{LanguageSnapshot, SnapshotInit}; + use code_ranker_plugin_api::PromptTemplate; + use code_ranker_plugin_api::node::Node; + + let node = |id: &str| Node { + id: id.to_string(), + kind: "file".into(), + name: id.to_string(), + parent: None, + attrs: Default::default(), + }; + let files = LevelGraph { + nodes: vec![node("{target}/a.rs"), node("{target}/b.rs")], + cycles: vec![CycleGroup { + kind: "mutual".into(), + nodes: vec!["{target}/a.rs".into(), "{target}/b.rs".into()], + }], + ..Default::default() + }; + let mut graphs = BTreeMap::new(); + graphs.insert("files".to_string(), files); + let mut languages = BTreeMap::new(); + languages.insert( + "rust".to_string(), + LanguageSnapshot { + graphs, + principles: Vec::new(), + prompt: PromptTemplate::default(), + }, + ); + Snapshot::new(SnapshotInit { + command: "report".into(), + workspace: "ws".into(), + target: "tgt".into(), + plugins: vec!["rust".into()], + config_file: None, + versions: BTreeMap::new(), + roots: BTreeMap::new(), + git: None, + timings: Vec::new(), + languages, + }) +} + +/// An `AnalyzeArgs` pointing at `input` with no analysis-only flags set. +fn args_for(input: std::path::PathBuf) -> AnalyzeArgs { + AnalyzeArgs { + input, + plugins: vec![], + config: vec![], + ignore_paths: vec![], + git_branch: None, + git_commit: None, + git_dirty_files: None, + git_origin: None, + } +} + +/// A snapshot input is read and re-gated under the current rules: a `mutual=on` +/// cycle rule turns the embedded mutual cycle into a single `rust` violation. +#[test] +fn analyze_from_snapshot_regates_cycle() { + let d = tempfile::tempdir().unwrap(); + let jp = d.path().join("s.json"); + fs::write(&jp, serde_json::to_string(&mk_snap_with_cycle()).unwrap()).unwrap(); + + let analyzed = analyze_input(&args_for(jp), &["mutual=on".into()], &[]).unwrap(); + assert_eq!(analyzed.snapshot.plugins, vec!["rust"]); + assert!( + analyzed.rules_by_lang.contains_key("rust"), + "per-language rules resolved from config" + ); + assert_eq!(analyzed.violations.len(), 1, "the mutual cycle is gated"); + let v = &analyzed.violations[0]; + assert_eq!(v.language, "rust"); + assert_eq!(v.graph, "files"); + assert_eq!(v.rule, "cycle.mutual", "the cycle rule fired"); +} + +/// Analysis-only flags are rejected against a snapshot input — there is no +/// source tree to apply them to. +#[test] +fn analyze_from_snapshot_rejects_analysis_only_flags() { + let d = tempfile::tempdir().unwrap(); + let jp = d.path().join("s.json"); + fs::write(&jp, serde_json::to_string(&mk_snap()).unwrap()).unwrap(); + + let mut with_plugins = args_for(jp.clone()); + with_plugins.plugins = vec!["rust".into()]; + let err = analyze_input(&with_plugins, &[], &[]) + .err() + .expect("plugins flag should be rejected") + .to_string(); + assert!( + err.contains("--plugins does not apply"), + "plugins rejected: {err}" + ); + + let mut with_ignore = args_for(jp); + with_ignore.ignore_paths = vec!["x/**".into()]; + let err = analyze_input(&with_ignore, &[], &[]) + .err() + .expect("ignore flag should be rejected") + .to_string(); + assert!( + err.contains("--ignore does not apply"), + "ignore rejected: {err}" + ); +} + +#[test] +fn viewer_embeds_snapshot_inline_and_round_trips() { + let snap = mk_snap(); + // review: current = snapshot, baseline = null + let html = code_ranker_viewer::render_html_viewer(None, Some(&snap)); + assert!( + html.contains(r#""#), + "baseline is null in review mode" + ); + let back = code_ranker_viewer::extract_embedded_snapshot(&html, "cs-current") + .expect("cs-current present") + .unwrap(); + assert_eq!( + back.plugins, + vec!["rust"], + "round-trips through embed/extract" + ); + assert!( + code_ranker_viewer::extract_embedded_snapshot(&html, "cs-baseline").is_none(), + "null baseline extracts to None" + ); +} + +#[test] +fn load_snapshot_any_reads_json_and_html() { + let snap = mk_snap(); + let d = tempfile::tempdir().unwrap(); + + let jp = d.path().join("s.json"); + fs::write(&jp, serde_json::to_string(&snap).unwrap()).unwrap(); + assert_eq!( + load_snapshot_any(&jp).unwrap().plugins, + vec!["rust"], + "from .json" + ); + + let hp = d.path().join("r.html"); + fs::write( + &hp, + code_ranker_viewer::render_html_viewer(None, Some(&snap)), + ) + .unwrap(); + assert_eq!( + load_snapshot_any(&hp).unwrap().plugins, + vec!["rust"], + "from embedded .html" + ); +} + +#[test] +fn load_snapshot_rejects_schema_version_mismatch() { + let d = tempfile::tempdir().unwrap(); + let jp = d.path().join("old.json"); + // A snapshot tagged with a different schema version must be rejected + // with a structured error (not silently mis-parsed). + let mut v = serde_json::to_value(mk_snap()).unwrap(); + v["schema_version"] = serde_json::Value::String("1".into()); + fs::write(&jp, serde_json::to_string(&v).unwrap()).unwrap(); + let err = format!("{:#}", load_snapshot_any(&jp).unwrap_err()); + assert!(err.contains("schema_version"), "schema error: {err}"); + assert!(err.contains("\"1\""), "names the offending version: {err}"); +} diff --git a/crates/code-ranker-cli/src/check.rs b/crates/code-ranker-cli/src/check.rs index 88c637be..4b4af074 100644 --- a/crates/code-ranker-cli/src/check.rs +++ b/crates/code-ranker-cli/src/check.rs @@ -41,7 +41,7 @@ pub(crate) fn run_check( ) -> Result<()> { let a = analyze_input(args, cycle_rules, thresholds)?; let project = project_name(&a.snapshot.target); - let plugin = a.snapshot.plugin.clone(); + let plugins = a.snapshot.plugins.join(", "); // Without --baseline the gate is absolute: every violation counts. With // --baseline it is relative: only violations not already present in the @@ -50,14 +50,25 @@ pub(crate) fn run_check( None => (a.violations, None), Some(bpath) => { let base = load_snapshot_any(bpath)?; - let mut bgraphs = base.graphs.clone(); - if let Some(level) = bgraphs.get_mut("files") { - config::apply_cycle_rules(&mut level.cycles, &mut level.nodes, &a.rules.cycles); + let mut blanguages = base.languages.clone(); + for (lang, ls) in blanguages.iter_mut() { + if let Some(level) = ls.graphs.get_mut("files") { + let cycles = a + .rules_by_lang + .get(lang) + .map(|r| r.cycles) + .unwrap_or_default(); + config::apply_cycle_rules(&mut level.cycles, &mut level.nodes, &cycles); + } } - let base_v = config::check_violations(&bgraphs, &a.rules); - let sig = |v: &config::Violation| (v.rule.clone(), v.location.clone()); - let base_sigs: HashSet<(String, String)> = base_v.iter().map(sig).collect(); - let cur_sigs: HashSet<(String, String)> = a.violations.iter().map(sig).collect(); + let base_v = config::check_violations_all(&blanguages, &a.rules_by_lang); + // Dedup key includes language so violations from different languages + // with identical rule+location don't collide. + let sig = + |v: &config::Violation| (v.language.clone(), v.rule.clone(), v.location.clone()); + let base_sigs: HashSet<(String, String, String)> = base_v.iter().map(sig).collect(); + let cur_sigs: HashSet<(String, String, String)> = + a.violations.iter().map(sig).collect(); let resolved = base_sigs.iter().filter(|s| !cur_sigs.contains(*s)).count(); let new_v: Vec = a .violations @@ -100,31 +111,44 @@ pub(crate) fn run_check( None => &findings[..], }; - // Diagnostic copy (why / fix / title) is resolved from the active snapshot's + // Diagnostic copy (why / fix / title) is resolved from the snapshot's // `files`-level specs — the metric `description`/`remediation` and cycle-kind - // vocab — so no rule prose lives in the CLI. - let files = a.snapshot.graphs.get("files"); - let empty_na: BTreeMap = BTreeMap::new(); - let empty_ck: BTreeMap = BTreeMap::new(); - let node_attributes = files.map(|g| &g.node_attributes).unwrap_or(&empty_na); - let cycle_kinds = files.map(|g| &g.cycle_kinds).unwrap_or(&empty_ck); + // vocab — so no rule prose lives in the CLI. Merge specs across all languages + // (last-wins) so diagnostics work regardless of which language a violation + // comes from. + let (node_attributes, cycle_kinds) = merged_specs_pub(&a.snapshot.languages); emit_diagnostics( shown, total, - &plugin, + &plugins, &project, output_format, verdict, &scope_note, - node_attributes, - cycle_kinds, + &node_attributes, + &cycle_kinds, ); // Surface the current measured values as ready-to-paste config blocks only on // request (`--suggest-config`), human output only — machine formats stay pure. if suggest_config && matches!(output_format, OutputFormat::Human) { - print_current_values(&a.snapshot.graphs, &a.cycles); + // Union the `files` graphs across all languages for the values dump. + let all_graphs: BTreeMap = a + .snapshot + .languages + .values() + .flat_map(|ls| ls.graphs.iter().map(|(k, v)| (k.clone(), v.clone()))) + .collect(); + // The suggested `[rules.cycles]` block uses the first language's cycle + // policy (the values dump is an aggregate convenience across languages). + let cycles = a + .rules_by_lang + .values() + .next() + .map(|r| r.cycles) + .unwrap_or_default(); + print_current_values(&all_graphs, &cycles); } if total > 0 && !exit_zero { @@ -230,7 +254,7 @@ fn render_prompt( one, keeping existing behavior and public APIs intact.\n" ); for v in violations { - let doc = config::rule_doc(&v.rule, node_attributes, cycle_kinds); + let doc = config::rule_doc(&v.rule, &v.language, node_attributes, cycle_kinds); let title = doc .as_ref() .and_then(|d| d.title.clone()) @@ -281,6 +305,27 @@ fn render_prompt( s } +/// Merge `node_attributes` and `cycle_kinds` specs across all languages for use +/// in diagnostic copy resolution. Last-wins per key is fine: the same metric +/// name carries the same description across languages by convention, and a +/// language-specific refinement is preferable to nothing. +pub(crate) fn merged_specs_pub( + languages: &std::collections::BTreeMap, +) -> ( + BTreeMap, + BTreeMap, +) { + let mut na = BTreeMap::new(); + let mut ck = BTreeMap::new(); + for ls in languages.values() { + if let Some(files) = ls.graphs.get("files") { + na.extend(files.node_attributes.clone()); + ck.extend(files.cycle_kinds.clone()); + } + } + (na, ck) +} + /// Whether a violation's repo-relative path falls under one of the `--focus-path` /// entries. An entry matches a file exactly or, treated as a folder, anything /// beneath it (`crates/a/src` matches `crates/a/src/x.rs`). Leading `./` and a @@ -360,8 +405,11 @@ fn print_human_diagnostics( println!("Full rule reference: {DOCS_URL}/code-ranker-cli/ERRORS.md\n"); for v in violations { - let doc = config::rule_doc(&v.rule, node_attributes, cycle_kinds); - println!("{} · {} · {} graph", v.rule, v.group, v.graph); + let doc = config::rule_doc(&v.rule, &v.language, node_attributes, cycle_kinds); + println!( + "{} · {} · {} · {} graph", + v.rule, v.language, v.group, v.graph + ); if !v.location.is_empty() { println!(" where {}", v.location); } @@ -382,7 +430,7 @@ fn print_human_diagnostics( if let Some(fix) = fix { println!(" fix {fix}"); } - let tune = config::rule_tuning(&v.rule); + let tune = config::rule_tuning(&v.rule, &v.language); if !tune.is_empty() { println!(" tune {tune}"); } diff --git a/crates/code-ranker-cli/src/check/sarif.rs b/crates/code-ranker-cli/src/check/sarif.rs index 81b18dff..87a911c8 100644 --- a/crates/code-ranker-cli/src/check/sarif.rs +++ b/crates/code-ranker-cli/src/check/sarif.rs @@ -48,7 +48,7 @@ pub(crate) fn sarif_document( let rules: Vec = seen .iter() .map(|v| { - let doc = config::rule_doc(&v.rule, node_attributes, cycle_kinds); + let doc = config::rule_doc(&v.rule, &v.language, node_attributes, cycle_kinds); let title = doc .as_ref() .and_then(|d| d.title.clone()) @@ -84,7 +84,7 @@ pub(crate) fn sarif_document( // as "new". The value is the readable composite key (no hashing) — a // metric finding has at most one `(rule, location)`, so it is unique. "partialFingerprints": { - "codeRankerRuleLocation/v1": format!("{}:{}", v.rule, v.location), + "codeRankerRuleLocation/v1": format!("{}:{}:{}", v.language, v.rule, v.location), }, "properties": { "group": v.group, "graph": v.graph, "weight": v.weight }, }); @@ -135,7 +135,7 @@ pub(crate) fn codequality_document(violations: &[config::Violation]) -> String { // Readable composite identity (no hashing) — a finding has at most // one (rule, location), so it is unique; line excluded so a shift // does not reopen it. - "fingerprint": format!("{}:{}", v.rule, v.location), + "fingerprint": format!("{}:{}:{}", v.language, v.rule, v.location), "severity": "major", "location": { "path": violation_rel_path(&v.location).unwrap_or(v.location.as_str()), diff --git a/crates/code-ranker-cli/src/check/values.rs b/crates/code-ranker-cli/src/check/values.rs index c662f3f7..10f98e8e 100644 --- a/crates/code-ranker-cli/src/check/values.rs +++ b/crates/code-ranker-cli/src/check/values.rs @@ -27,7 +27,7 @@ pub(super) fn print_current_values( println!( "# cycles: max allowed count per kind (today's count — raise only to allow more; false = off)" ); - println!("[rules.cycles]"); + println!("[plugins.base.rules.cycles]"); for (key, kind, rule) in [ ("mutual", "mutual", cycles.mutual), ("chain", "chain", cycles.chain), @@ -109,7 +109,11 @@ fn print_scope_values(scope: &str, level: &LevelGraph) { } // Preserve the display order from `keys` (BTreeMap would re-sort). let vals: Vec<(&str, f64)> = keys.iter().map(|k| (*k, maxima[*k])).collect(); - print_toml_block(&format!("[rules.thresholds.{scope}]"), &vals, false); + print_toml_block( + &format!("[plugins.base.rules.thresholds.{scope}]"), + &vals, + false, + ); } /// Print one TOML table, one `metric = value` line per non-zero metric. With diff --git a/crates/code-ranker-cli/src/check_test.rs b/crates/code-ranker-cli/src/check_test.rs index 2fb0f5cc..0ed635a8 100644 --- a/crates/code-ranker-cli/src/check_test.rs +++ b/crates/code-ranker-cli/src/check_test.rs @@ -2,6 +2,7 @@ use super::*; fn viol(location: &str, line: Option) -> config::Violation { config::Violation { + language: "rust".into(), rule: "threshold.file.loc".into(), group: "SIZ".into(), graph: "files", @@ -40,6 +41,7 @@ fn path_matches_ignores_leading_dot_slash_and_trailing_slash() { #[test] fn rule_matches_full_id_bare_id_and_group() { let v = config::Violation { + language: "rust".into(), rule: "check.inline_tests_too_large".into(), group: "TST".into(), graph: "files", @@ -152,8 +154,11 @@ fn codequality_issue_has_fingerprint_path_and_line() { assert_eq!(issue["severity"], "major"); assert_eq!(issue["location"]["path"], "src/x.rs"); assert_eq!(issue["location"]["lines"]["begin"], 7); - // Stable identity = rule:location, no line (so a shift does not reopen it). - assert_eq!(issue["fingerprint"], "threshold.file.loc:{target}/src/x.rs"); + // Stable identity = language:rule:location, no line (so a shift does not reopen it). + assert_eq!( + issue["fingerprint"], + "rust:threshold.file.loc:{target}/src/x.rs" + ); } #[test] @@ -175,7 +180,7 @@ fn sarif_partial_fingerprint_is_rule_and_location() { let fp = &v["runs"][0]["results"][0]["partialFingerprints"]; assert_eq!( fp["codeRankerRuleLocation/v1"], - "threshold.file.loc:{target}/src/x.rs" + "rust:threshold.file.loc:{target}/src/x.rs" ); } diff --git a/crates/code-ranker-cli/src/cli.rs b/crates/code-ranker-cli/src/cli.rs index 82757dfb..3764bd7e 100644 --- a/crates/code-ranker-cli/src/cli.rs +++ b/crates/code-ranker-cli/src/cli.rs @@ -64,10 +64,13 @@ pub(crate) struct AnalyzeArgs { #[arg(default_value = ".")] pub(crate) input: PathBuf, - /// Plugin: rust | python | javascript | auto. Default: auto (detect by markers). + /// Plugins to activate: `rust`, `python`, `javascript`, etc. Comma-separated + /// or repeated (`--plugins rust,markdown` or `--plugins rust --plugins markdown`). + /// Empty (the default) → auto-detect all languages whose markers are present. + /// Overrides both `plugins = [...]` in the config file and auto-detection. /// Only applies when the input is a directory. - #[arg(long)] - pub(crate) plugin: Option, + #[arg(long, value_delimiter = ',')] + pub(crate) plugins: Vec, /// Config file path, or inline `KEY=VALUE` override. Repeatable: files layer /// in command-line order (later wins) over the built-in defaults; passing any @@ -210,37 +213,33 @@ pub(crate) enum Command { #[arg(long = "output.codequality.path", value_name = "PATH")] output_codequality_path: Option, - /// Emit the AI fix-prompt, auto-targeted at the single worst module of the - /// worst-violating principle (requires `--top 1`; default to a `…-{principle}.md` - /// file, where {principle} is that principle). - #[arg(long = "output.prompt")] - output_prompt: bool, - /// Emit the console triage scorecard (default to stdout). #[arg(long = "output.scorecard")] output_scorecard: bool, - /// AI-prompt destination: a path or name template (extra placeholder - /// {principle}), or `stdout`/`-`. Selects the prompt format. - #[arg(long = "output.prompt.path", value_name = "PATH")] - output_prompt_path: Option, - /// Scorecard destination: a path or name template, or `stdout`/`-` /// (the default). Selects the scorecard format. #[arg(long = "output.scorecard.path", value_name = "PATH")] output_scorecard_path: Option, - /// Focus the scorecard / prompt on one **metric** (`hk`, `sloc`, … — + /// Focus the scorecard / `--prompt` on one **metric** (`hk`, `sloc`, … — /// case-insensitive, matched by value so it works with or without a /// configured threshold) or **principle** id (`LSP`, `ADP`, …). A metric /// frames the output by the metric itself (no SOLID wrapper); a principle by - /// that design principle. Without it, the scorecard spans every principle and - /// the prompt auto-targets the worst. (On `check`, `--focus` instead filters - /// the gate by rule/group — a different operation.) + /// that design principle. Without it, the scorecard spans every principle. + /// (On `check`, `--focus` instead filters the gate by rule/group — a + /// different operation.) #[arg(long = "focus", value_name = "METRIC | PRINCIPLE")] focus: Option, - /// Restrict the scorecard / prompt to modules under these paths (repeatable). + /// Select the active language for `--focus`, `--prompt`, and the scorecard + /// when the snapshot contains multiple languages. Required when `--focus` / + /// `--prompt ` matches a metric or principle in more than one language; + /// unambiguous single-language snapshots do not need it. + #[arg(long, value_name = "NAME")] + language: Option, + + /// Restrict the scorecard / `--prompt` to modules under these paths (repeatable). /// The whole project is still analyzed (the graph needs it), but only modules /// located under one of these paths are ranked and listed. Paths are /// repo-relative (matching the reported location); a folder matches everything @@ -254,7 +253,7 @@ pub(crate) enum Command { severity: Vec, /// Rows the scorecard shows (`--top 1` = the single worst module). - /// `--output.prompt` requires exactly `--top 1`. Prompt/scorecard only. + /// Also shapes the `--prompt ` ranked module list. Scorecard / prompt only. #[arg(long)] top: Option, @@ -270,32 +269,34 @@ pub(crate) enum Command { export_full_config: Option, /// Print the AI fix-prompt for one principle/metric to stdout and exit - /// (e.g. `--prompt HK`) — the named counterpart of `--output.prompt` - /// (which auto-targets the worst). Combine with `--top N` / `--focus-path` - /// to shape the ranked module list. + /// (e.g. `--prompt HK`). Combine with `--top N` / `--focus-path` to shape + /// the ranked module list, and `--language` to disambiguate across languages. #[arg(long = "prompt", value_name = "PRINCIPLE | METRIC")] prompt_id: Option, }, - /// Print a reference doc to stdout — no analysis, no `[input]`. The `` - /// is `ai` (the offline AI-agent playbook), `metrics` or `principles` (an index - /// of each), a metric category (`loc`, `complexity`, …), a metric key (`sloc`, - /// `hk`, …), or a principle id (`SRP`, `ADP`, … — including project-defined - /// `[principles.]` and `[metrics.]`). With no subject it prints a - /// catalog of every option. Config is auto-discovered from the current directory - /// (override with `--config`); `--plugin` resolves the language explicitly. + /// Print a reference doc to stdout — no analysis, no `[input]`. Docs are + /// per-language: the FIRST argument is the language (`rust`, `markdown`, … or + /// `base` for the language-agnostic catalog). The `` is `ai` (the + /// offline AI-agent playbook), `metrics` or `principles` (an index of each), a + /// metric category (`loc`, `complexity`, …), a metric key (`sloc`, `hk`, …), or + /// a principle id (`SRP`, `ADP`, …). Forms: + /// `code-ranker docs` → list the project's detected languages + + /// every language docs are available for; + /// `code-ranker docs ` → the full subject catalog for that language; + /// `code-ranker docs ` → the doc for that subject. + /// A subject without a language errors and lists the languages that carry it. Docs { + /// The language (`rust`, `markdown`, …, or `base`). Omit to list available + /// languages. Required before any ``. + language: Option, + /// What to print: `ai` | `metrics` | `principles` | a category | a metric - /// | a principle id. Omit to list every available subject. + /// | a principle id. Omit to print the language's full catalog. subject: Option, - /// Plugin: rust | python | javascript | auto. Resolves the language whose - /// principles / metric refinements drive the docs (skips auto-detection). - #[arg(long)] - plugin: Option, - /// Config file path, or inline `KEY=VALUE` override (repeatable) — consulted - /// for the `plugin` key and any project `[principles]` / `[metrics]`. + /// for project `[plugins.]` principles / metrics. #[arg(long, value_name = "PATH | KEY=VALUE")] config: Vec, }, diff --git a/crates/code-ranker-cli/src/config/defaults.toml b/crates/code-ranker-cli/src/config/defaults.toml index 1ab8e3ec..ce3b1599 100644 --- a/crates/code-ranker-cli/src/config/defaults.toml +++ b/crates/code-ranker-cli/src/config/defaults.toml @@ -6,11 +6,15 @@ # it. The merge reuses the plugins' `deep_merge`, so op-table list overrides # (`{add,remove,replace,clear,prepend}`) work for arrays here too. -# Default plugin: unset → `auto` (marker detection). A project pins one with -# `plugin = "rust"`; `--plugin` overrides both. +# Active plugins: empty / omitted → auto-detect all languages whose markers are +# present. A project pins specific ones with `[plugins] enabled = ["rust"]`; +# `--plugins` overrides. Per-language config lives under `[plugins.]`, with +# the shared base under `[plugins.base]` (inherited by every language). +# [plugins] +# enabled = ["rust"] -# ── Ignore filters (applied before cycles/metrics) ─────────────────────────── -[ignore] +# ── Ignore filters (applied before cycles/metrics) — per-language base ──────── +[plugins.base.ignore] # Glob patterns (repo-relative) dropped from the graph. None by default. paths = [] # Skip the language's test files — metrics and cycles describe production code. @@ -22,16 +26,24 @@ gitignore = true ignore_files = true hidden = true -# ── Gate rules (`check`) ───────────────────────────────────────────────────── +# ── Gate rules (`check`) — per-language base ────────────────────────────────── # Cycle checks per kind: `true`/0 = strict, `false` = off, N = allow up to N. -[rules.cycles] +[plugins.base.rules.cycles] mutual = true chain = true # Per-file metric thresholds (`file` is the only scope). No limits by default — # a project adds its own (e.g. `sloc = 800`). Values accept `_` separators and # K/M/G suffixes. -[rules.thresholds.file] +[plugins.base.rules.thresholds.file] + +# Markdown is documentation, not code: it ships NO gate by default — cross-document +# link cycles are perfectly normal, and there are no per-file thresholds. So `md` +# turns the strict base cycle rules OFF (a project can still opt back in under +# `[plugins.md]`). Thresholds inherit the empty base set (no limits). +[plugins.md.rules.cycles] +mutual = false +chain = false # ── Output artifacts (`report`) ────────────────────────────────────────────── # One section per format: `path` is a filename template; `enabled` forces the @@ -54,16 +66,12 @@ enabled = false path = ".code-ranker/{ts}-{git-hash-3}.codequality.json" enabled = false -# The prompt defaults to a per-principle Markdown file; the scorecard is a console -# overview defaulting to the stdout stream. Both stay OFF unless requested via -# `--output.prompt` / `--output.scorecard`; these just supply the default path. -[output.prompt] -path = ".code-ranker/{ts}-{git-hash-3}-{principle}.md" - +# The scorecard is a console overview defaulting to the stdout stream. It stays +# OFF unless requested via `--output.scorecard`; this just supplies the default path. [output.scorecard] path = "stdout" -# ── Optional extra analysis levels ─────────────────────────────────────────── +# ── Optional extra analysis levels — per-language base ─────────────────────── # Off by default → only the `files` level is emitted (default output unchanged). -[levels] +[plugins.base.levels] functions = false diff --git a/crates/code-ranker-cli/src/config/load.rs b/crates/code-ranker-cli/src/config/load.rs index 9645d65a..8b5fd99b 100644 --- a/crates/code-ranker-cli/src/config/load.rs +++ b/crates/code-ranker-cli/src/config/load.rs @@ -1,7 +1,8 @@ //! Config loading: discover `code-ranker.toml` (or `Cargo.toml` metadata), //! apply inline `KEY=VALUE` and `--cycle-rule` / `--threshold` CLI overrides. -use super::model::{Config, DEFAULTS, quote_suffixed_thresholds}; +use super::model::{Config, DEFAULTS, LANG_SECTION_KEYS, LangConfig}; +use super::thresholds::quote_suffixed_thresholds; use anyhow::{Context, Result}; use code_ranker_plugin_api::log; use code_ranker_plugin_api::toml_merge::deep_merge; @@ -14,17 +15,12 @@ mod overrides; // live in a sibling module and depend only on the `model` types, so this // import does not form a parent↔child cycle. use overrides::{apply_cli_overrides, apply_inline_overrides}; -// The remaining override helpers — and the model types they exercise — are -// referenced only by `load_test.rs` (via `super::*`); import them under -// `#[cfg(test)]` so normal builds stay warning-free. +// The remaining override helpers are referenced only by `load_test.rs` (via +// `super::*`); import them under `#[cfg(test)]` so normal builds stay warning-free. #[cfg(test)] -use super::model::{CycleRule, MetricThresholds}; -#[cfg(test)] -use overrides::{ - parse_cycle_rule, parse_on_off, parse_threshold_path, set_cycle, set_metric, set_threshold, - split_kv, -}; +use overrides::{parse_cycle_rule, parse_on_off, parse_threshold_path, split_kv}; +#[derive(Debug)] pub struct LoadedConfig { pub config: Config, pub source_file: Option, @@ -66,6 +62,17 @@ pub fn load( None => log::verbose("config: built-in defaults (no config file found)"), } let merged = layers.into_iter().fold(builtin_table(), deep_merge); + + // Hard-error on the legacy singular `plugin` key before serde gets a chance to + // reject it with a cryptic `unknown field`. This lets us give a directed + // migration message instead. + if merged.contains_key("plugin") { + anyhow::bail!( + "`plugin = \"x\"` is no longer supported; use `plugins = [\"x\"]` instead \ + (version 5.0 schema)" + ); + } + let mut config: Config = merged .clone() .try_into() @@ -73,6 +80,7 @@ pub fn load( apply_inline_overrides(&mut config, &inline)?; apply_cli_overrides(&mut config, ignore_paths, cycle_rules, thresholds)?; + normalize_plugin_aliases(&mut config); validate_thresholds(&config)?; validate_schema_version(&config, &source_file)?; Ok(LoadedConfig { @@ -82,6 +90,38 @@ pub fn load( }) } +/// Normalize language aliases (e.g. `js` → `javascript`) to canonical names across +/// the config, so every downstream lookup — the active `enabled` set and the +/// per-language `[plugins.]` blocks (read by `effective_plugin_config` / +/// `language_config`) — sees only canonical keys. Unknown tokens are left as-is +/// (resolution / dispatch reports them with the proper hint). The reserved `base` +/// key is never an alias, so it passes through; a block that collides with an +/// already-canonical block is deep-merged into it. +fn normalize_plugin_aliases(config: &mut Config) { + for name in &mut config.plugins.enabled { + *name = crate::plugin::to_canonical(name); + } + let blocks = std::mem::take(&mut config.plugins.languages); + for (key, block) in blocks { + let canon = if key == "base" { + key + } else { + crate::plugin::to_canonical(&key) + }; + match config.plugins.languages.remove(&canon) { + Some(existing) => { + config + .plugins + .languages + .insert(canon, deep_merge(existing, block)); + } + None => { + config.plugins.languages.insert(canon, block); + } + } + } +} + /// The built-in default config as a raw table — the merge base every discovered /// config layers over. Parsed from the embedded `defaults.toml` (the single /// source of default values). @@ -91,20 +131,55 @@ fn builtin_table() -> Table { .expect("embedded defaults.toml parses as a table") } +impl Config { + /// Resolve the per-language orchestrator config for `lang`: the reserved + /// `[plugins.base]` block deep-merged with `[plugins.]`, restricted to the + /// orchestrator sections ([`LANG_SECTION_KEYS`]). Built-in defaults live under + /// `[plugins.base]` in `defaults.toml`, so the result always carries them. + /// Lives here, next to the loader's merge machinery, rather than in `model` + /// (the data-shape module), keeping config *resolution* out of the data model. + pub fn language_config(&self, lang: &str) -> Result { + let pick = |block: &toml::Table| -> toml::Table { + block + .iter() + .filter(|(k, _)| LANG_SECTION_KEYS.contains(&k.as_str())) + .map(|(k, v)| (k.clone(), v.clone())) + .collect() + }; + let mut overlay = toml::Table::new(); + for key in ["base", lang] { + if let Some(block) = self.plugins.languages.get(key) { + overlay = deep_merge(overlay, pick(block)); + } + } + toml::Value::Table(overlay) + .try_into() + .with_context(|| format!("building the effective config for language {lang:?}")) + } +} + /// Validate every configured threshold key once the full config is known: a key -/// is legal if it is a registry per-file metric OR a project `[metrics.]`. -/// Deferred here (not in the deserializer) so a custom metric — invisible to the -/// `MetricThresholds` deserializer — is accepted while a typo still fails fast. +/// is legal if it is a registry per-file metric, a project `[metrics.]`, OR +/// a metric key declared under any `[languages.*].metrics` table (a per-language +/// custom metric is a valid global-threshold target). +/// Deferred here (not in the deserializer) so custom metrics — invisible to the +/// `MetricThresholds` deserializer — are accepted while a typo still fails fast. fn validate_thresholds(cfg: &Config) -> Result<()> { - for key in cfg.rules.thresholds.file.limits.keys() { - if super::metrics::is_threshold_metric(key) || cfg.metrics.contains_key(key) { - continue; + // Thresholds are per-language now: validate each configured language's effective + // `[rules.thresholds.file]` (base ⊕ ) against that language's metric + // vocabulary (registry metrics ∪ its own `[metrics.]`). + for lang in cfg.plugins.languages.keys() { + let lc = cfg.language_config(lang)?; + for key in lc.rules.thresholds.file.limits.keys() { + if super::metrics::is_threshold_metric(key) || lc.metrics.contains_key(key) { + continue; + } + anyhow::bail!( + "unknown threshold metric {key:?} under [plugins.{lang}]; expected a per-file \ + metric (e.g. sloc, loc, cyclomatic, cognitive, hk, fan_in, fan_out, mi, volume, \ + bugs) or a custom [plugins.{lang}.metrics.{key}] / [plugins.base.metrics.{key}]" + ); } - anyhow::bail!( - "unknown threshold metric {key:?}; expected a per-file metric (e.g. sloc, loc, \ - cyclomatic, cognitive, hk, fan_in, fan_out, mi, volume, bugs) or a custom \ - [metrics.{key}] defined in this config" - ); } Ok(()) } diff --git a/crates/code-ranker-cli/src/config/load/overrides.rs b/crates/code-ranker-cli/src/config/load/overrides.rs index 67642667..c3e78e81 100644 --- a/crates/code-ranker-cli/src/config/load/overrides.rs +++ b/crates/code-ranker-cli/src/config/load/overrides.rs @@ -1,11 +1,15 @@ //! Apply the transient per-run flag overrides — inline `--config KEY=VALUE`, //! `--cycle-rule`, `--threshold` — onto an already-deserialized [`Config`]. //! -//! These helpers depend only on their arguments and the config-model types from -//! [`super::super::model`]; they never reference items defined in the parent -//! `load` module, so the parent can import them back without forming a cycle. +//! The per-language sections (`ignore`/`rules`/`metrics`/`levels`/`report`/ +//! `principles`) live in raw `[plugins.]` tables (read later via +//! `Config::language_config`), so their overrides are written into those raw tables +//! here rather than into typed fields. A bare top-level key (e.g. `ignore.tests`, +//! `rules.thresholds.file.hk`) targets the shared `[plugins.base]` layer; a +//! `plugins..` key targets one language. Only `[output]` / `[templates]` +//! remain typed (global), so those keep their explicit arms. -use crate::config::model::{Config, CycleRule, MetricThresholds, parse_number}; +use crate::config::model::{Config, CycleRule, parse_number}; use anyhow::{Context, Result}; pub(crate) fn apply_cli_overrides( @@ -14,18 +18,32 @@ pub(crate) fn apply_cli_overrides( cycle_rules: &[String], thresholds: &[String], ) -> Result<()> { - cfg.ignore.paths.extend_from_slice(ignore_paths); + // `--ignore` paths extend the base-language ignore globs. + if !ignore_paths.is_empty() { + let base = base_table(cfg); + let arr = ensure_array(base, &["ignore", "paths"]); + arr.extend(ignore_paths.iter().map(|p| toml::Value::String(p.clone()))); + } for raw in cycle_rules { let (kind, state) = split_kv(raw, "cycle-rule")?; - set_cycle(cfg, kind, parse_cycle_rule(state)?)?; + // Validate kind / state, then store as a raw cycle value on the base layer. + let value = cycle_value(parse_cycle_rule(state)?); + if kind != "mutual" && kind != "chain" { + anyhow::bail!("unknown cycle kind {kind:?}; expected mutual|chain"); + } + set_path(base_table(cfg), &["rules", "cycles", kind], value); } for raw in thresholds { let (path, val_str) = split_kv(raw, "threshold")?; let val = parse_number(val_str).with_context(|| format!("in --threshold {raw}"))?; let (scope, metric) = parse_threshold_path(path)?; - set_threshold(cfg, scope, metric, val)?; + set_path( + base_table(cfg), + &["rules", "thresholds", scope, metric], + number_value(val), + ); } Ok(()) @@ -36,35 +54,30 @@ pub(crate) fn apply_inline_overrides(cfg: &mut Config, entries: &[&str]) -> Resu let (key, value) = raw .split_once('=') .with_context(|| format!("--config override must be KEY=VALUE, got: {raw}"))?; + // Normalize the `ignore.tests` aliases to the canonical key so a raw-table + // write overwrites the default `tests` rather than adding a duplicate + // alias key (which the alias-aware deserializer would reject). + let key = match key { + "ignore.test_modules" | "ignore.test-modules" => "ignore.tests", + other => other, + }; match key { - "plugin" => cfg.plugin = Some(value.to_string()), - "ignore.tests" | "ignore.test_modules" => cfg.ignore.tests = parse_on_off(value)?, - "ignore.dev_only_crates" => cfg.ignore.dev_only_crates = parse_on_off(value)?, - "ignore.gitignore" => cfg.ignore.gitignore = parse_on_off(value)?, - "ignore.ignore_files" => cfg.ignore.ignore_files = parse_on_off(value)?, - "ignore.hidden" => cfg.ignore.hidden = parse_on_off(value)?, - "ignore.paths" => cfg - .ignore - .paths - .extend(value.split(',').map(|s| s.trim().to_string())), + // The active-language list. + "plugins" | "plugins.enabled" => { + cfg.plugins.enabled = value + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + } + // Global, typed output config. "output.json.path" => cfg.output.json.path = Some(value.to_string()), "output.html.path" => cfg.output.html.path = Some(value.to_string()), "output.json.enabled" => cfg.output.json.enabled = Some(parse_on_off(value)?), "output.html.enabled" => cfg.output.html.enabled = Some(parse_on_off(value)?), - "levels.functions" => cfg.levels.functions = parse_on_off(value)?, - _ if key.strip_prefix("rules.cycles.").is_some() => { - let kind = key.strip_prefix("rules.cycles.").unwrap(); - set_cycle(cfg, kind, parse_cycle_rule(value)?)?; - } - _ if key.strip_prefix("rules.thresholds.").is_some() => { - let rest = key.strip_prefix("rules.thresholds.").unwrap(); - let (scope, metric) = parse_threshold_path(rest)?; - let val = parse_number(value).with_context(|| format!("in --config {raw}"))?; - set_threshold(cfg, scope, metric, val)?; - } + // Global, typed templates config. "templates.prompt" => cfg.templates.prompt = Some(value.to_string()), _ if key.strip_prefix("templates.languages.").is_some() => { - // `templates.languages..=path` — override one doc fragment. let rest = key.strip_prefix("templates.languages.").unwrap(); let (lang, id) = rest.split_once('.').with_context(|| { format!( @@ -77,19 +90,101 @@ pub(crate) fn apply_inline_overrides(cfg: &mut Config, entries: &[&str]) -> Resu .or_default() .insert(id.to_string(), value.to_string()); } + // `plugins..=value` — a leaf override into one language's + // raw block (scalars / comma-lists only; deep tables need a TOML block). + _ if key.strip_prefix("plugins.").is_some() => { + let rest = key.strip_prefix("plugins.").unwrap(); + let (lang, path) = rest.split_once('.').with_context(|| { + format!("--config plugins key must be plugins.., got: {key}") + })?; + let segs: Vec<&str> = path.split('.').collect(); + let table = cfg.plugins.languages.entry(lang.to_string()).or_default(); + set_path(table, &segs, parse_leaf_value(value)); + } + // A known per-language section key (e.g. `ignore.tests`, + // `rules.thresholds.file.hk`, `levels.functions`, `metrics.*`) applies to + // every language via the shared `[plugins.base]` layer. + _ if key.split_once('.').is_some_and(|(head, _)| { + crate::config::model::LANG_SECTION_KEYS.contains(&head) + }) => + { + let segs: Vec<&str> = key.split('.').collect(); + set_path(base_table(cfg), &segs, parse_leaf_value(value)); + } other => anyhow::bail!("unknown config key {other:?}"), } } Ok(()) } -pub(crate) fn set_cycle(cfg: &mut Config, kind: &str, rule: CycleRule) -> Result<()> { - match kind { - "mutual" => cfg.rules.cycles.mutual = rule, - "chain" => cfg.rules.cycles.chain = rule, - other => anyhow::bail!("unknown cycle kind {other:?}; expected mutual|chain"), +/// The raw `[plugins.base]` override table, created on first use. +fn base_table(cfg: &mut Config) -> &mut toml::Table { + cfg.plugins.languages.entry("base".to_string()).or_default() +} + +/// Insert `value` at a dotted key path within a raw table, creating intermediate +/// tables (replacing a non-table value in the way, which can only be a misuse). +fn set_path(table: &mut toml::Table, path: &[&str], value: toml::Value) { + match path { + [] => {} + [last] => { + table.insert((*last).to_string(), value); + } + [head, rest @ ..] => { + let entry = table + .entry((*head).to_string()) + .or_insert_with(|| toml::Value::Table(toml::Table::new())); + if !entry.is_table() { + *entry = toml::Value::Table(toml::Table::new()); + } + set_path( + entry.as_table_mut().expect("just ensured table"), + rest, + value, + ); + } + } +} + +/// Get (creating if needed) a mutable array at a dotted path. +fn ensure_array<'a>(table: &'a mut toml::Table, path: &[&str]) -> &'a mut Vec { + // Walk/create intermediate tables, then ensure the leaf is an array. + let (head, rest) = path.split_first().expect("non-empty path"); + if rest.is_empty() { + let entry = table + .entry((*head).to_string()) + .or_insert_with(|| toml::Value::Array(Vec::new())); + if !entry.is_array() { + *entry = toml::Value::Array(Vec::new()); + } + return entry.as_array_mut().expect("just ensured array"); + } + let entry = table + .entry((*head).to_string()) + .or_insert_with(|| toml::Value::Table(toml::Table::new())); + if !entry.is_table() { + *entry = toml::Value::Table(toml::Table::new()); + } + ensure_array(entry.as_table_mut().expect("just ensured table"), rest) +} + +/// The raw TOML value for a cycle rule: `true` (strict / `Max(0)`), `false` +/// (off), or an integer budget. +fn cycle_value(rule: CycleRule) -> toml::Value { + match rule { + CycleRule::Off => toml::Value::Boolean(false), + CycleRule::Max(0) => toml::Value::Boolean(true), + CycleRule::Max(n) => toml::Value::Integer(n as i64), + } +} + +/// A numeric threshold as a TOML value (integer when whole, else float). +fn number_value(v: f64) -> toml::Value { + if v.fract() == 0.0 && v.abs() < i64::MAX as f64 { + toml::Value::Integer(v as i64) + } else { + toml::Value::Float(v) } - Ok(()) } pub(crate) fn parse_cycle_rule(s: &str) -> Result { @@ -105,29 +200,12 @@ pub(crate) fn parse_cycle_rule(s: &str) -> Result { pub(crate) fn parse_threshold_path(path: &str) -> Result<(&str, &str)> { let parts: Vec<&str> = path.split('.').collect(); match parts.as_slice() { - [scope, metric] => Ok((scope, metric)), + [scope, metric] if *scope == "file" => Ok((scope, metric)), + [scope, _] => anyhow::bail!("unknown threshold scope {scope:?}; the only scope is `file`"), _ => anyhow::bail!("threshold must be file.METRIC, got: {path}"), } } -pub(crate) fn set_threshold(cfg: &mut Config, scope: &str, metric: &str, val: f64) -> Result<()> { - let st = match scope { - "file" => &mut cfg.rules.thresholds.file, - other => { - anyhow::bail!("unknown threshold scope {other:?}; the only scope is `file`") - } - }; - set_metric(st, metric, val) -} - -pub(crate) fn set_metric(bucket: &mut MetricThresholds, metric: &str, val: f64) -> Result<()> { - // Validity (registry metric ∪ custom `[metrics]`) is checked centrally in - // `validate_thresholds`, once the whole config — including `[metrics]` — is - // known; a CLI/inline override only records the limit here. - bucket.set(metric.to_string(), val); - Ok(()) -} - pub(crate) fn split_kv<'a>(s: &'a str, flag: &str) -> Result<(&'a str, &'a str)> { s.split_once('=') .with_context(|| format!("--{flag} must be key=value, got: {s}")) @@ -140,3 +218,37 @@ pub(crate) fn parse_on_off(s: &str) -> Result { other => anyhow::bail!("expected on|off, got {:?}", other), } } + +/// Parse a leaf CLI value for a raw-table override. +/// +/// Supported forms (scalars + comma-lists only — deep nested tables must use a full +/// `[plugins.]` TOML block): +/// - `"on"` / `"true"` → `true`; `"off"` / `"false"` → `false` +/// - a bare integer (no decimal) → TOML integer +/// - a bare float (has `.`) → TOML float +/// - a comma-separated list (`a,b,c`) → TOML array of strings +/// - anything else → TOML string (suffixed numbers like `8K` are parsed later by +/// the threshold deserializer) +pub(crate) fn parse_leaf_value(s: &str) -> toml::Value { + match s { + "true" | "on" => toml::Value::Boolean(true), + "false" | "off" => toml::Value::Boolean(false), + _ if s.contains(',') => { + let arr: Vec = s + .split(',') + .map(|item| toml::Value::String(item.trim().to_string())) + .collect(); + toml::Value::Array(arr) + } + _ => { + if !s.contains('.') { + if let Ok(i) = s.parse::() { + return toml::Value::Integer(i); + } + } else if let Ok(f) = s.parse::() { + return toml::Value::Float(f); + } + toml::Value::String(s.to_string()) + } + } +} diff --git a/crates/code-ranker-cli/src/config/load_test.rs b/crates/code-ranker-cli/src/config/load_test.rs index 4f8c502d..cb2df867 100644 --- a/crates/code-ranker-cli/src/config/load_test.rs +++ b/crates/code-ranker-cli/src/config/load_test.rs @@ -1,4 +1,5 @@ use super::*; +use crate::config::model::{CycleRule, MetricThresholds}; /// A config-file body prefixed with the required `version` line. Fixtures must not /// hardcode the number — it comes from the single `CONFIG_VERSION` constant. @@ -17,19 +18,20 @@ fn load_merges_explicit_config_over_builtin_defaults() { let cfg = dir.path().join("ci.toml"); std::fs::write( &cfg, - v("[ignore]\ntests = false\n[rules.thresholds.file]\nhk = \"1M\"\n"), + v("[plugins.base.ignore]\ntests = false\n[plugins.base.rules.thresholds.file]\nhk = \"1M\"\n"), ) .unwrap(); let loaded = load(dir.path(), &[cfg.display().to_string()], &[], &[], &[]).unwrap(); let c = &loaded.config; + let lc = c.language_config("base").unwrap(); // Overridden by the file: - assert!(!c.ignore.tests); - assert_eq!(c.rules.thresholds.file.get("hk"), Some(1_000_000.0)); + assert!(!lc.ignore.tests); + assert_eq!(lc.rules.thresholds.file.get("hk"), Some(1_000_000.0)); // Inherited from the built-in defaults (not in the file): - assert!(c.ignore.gitignore && c.ignore.hidden); - assert_eq!(c.rules.cycles.mutual, CycleRule::Max(0)); + assert!(lc.ignore.gitignore && lc.ignore.hidden); + assert_eq!(lc.rules.cycles.mutual, CycleRule::Max(0)); assert!(c.output.json.path.is_some()); // The merged raw table is exposed for `--export-full-config`. assert!(loaded.merged.contains_key("output")); @@ -48,7 +50,7 @@ fn load_requires_matching_schema_version() { let err = || format!("{:#}", run().err().unwrap()); // Missing `version` → error naming the required value. - std::fs::write(&cfg, "[ignore]\ntests = false\n").unwrap(); + std::fs::write(&cfg, "[plugins.base.ignore]\ntests = false\n").unwrap(); assert!(err().contains("missing the required `version`")); // Older schema → migrate hint. @@ -68,7 +70,7 @@ fn load_requires_matching_schema_version() { assert!(err().contains("migrate the config, or upgrade")); // Matching schema → ok. - std::fs::write(&cfg, v("[ignore]\ntests = false\n")).unwrap(); + std::fs::write(&cfg, v("[plugins.base.ignore]\ntests = false\n")).unwrap(); assert!(run().is_ok()); } @@ -78,8 +80,12 @@ fn load_layers_multiple_config_files_in_order_last_wins() { let dir = tempfile::tempdir().unwrap(); let base = dir.path().join("base.toml"); let over = dir.path().join("over.toml"); - std::fs::write(&base, v("[rules.thresholds.file]\nhk = 100\nsloc = 800\n")).unwrap(); - std::fs::write(&over, "[rules.thresholds.file]\nhk = 5\n").unwrap(); + std::fs::write( + &base, + v("[plugins.base.rules.thresholds.file]\nhk = 100\nsloc = 800\n"), + ) + .unwrap(); + std::fs::write(&over, "[plugins.base.rules.thresholds.file]\nhk = 5\n").unwrap(); let loaded = load( dir.path(), @@ -93,7 +99,13 @@ fn load_layers_multiple_config_files_in_order_last_wins() { &[], ) .unwrap(); - let t = &loaded.config.rules.thresholds.file; + let t = &loaded + .config + .language_config("base") + .unwrap() + .rules + .thresholds + .file; // `over.toml` overrode `hk`; `base.toml`'s `sloc` then beaten by the inline. assert_eq!(t.get("hk"), Some(5.0)); assert_eq!(t.get("sloc"), Some(1.0)); @@ -111,12 +123,22 @@ fn load_auto_discovers_code_ranker_toml_in_workspace() { let dir = tempfile::tempdir().unwrap(); std::fs::write( dir.path().join("code-ranker.toml"), - v("[rules.thresholds.file]\nhk = 42\n"), + v("[plugins.base.rules.thresholds.file]\nhk = 42\n"), ) .unwrap(); let loaded = load(dir.path(), &[], &[], &[], &[]).unwrap(); - assert_eq!(loaded.config.rules.thresholds.file.get("hk"), Some(42.0)); + assert_eq!( + loaded + .config + .language_config("base") + .unwrap() + .rules + .thresholds + .file + .get("hk"), + Some(42.0) + ); let src = loaded.source_file.expect("discovered source file"); assert!(src.ends_with("code-ranker.toml"), "{src}"); } @@ -129,14 +151,24 @@ fn load_auto_discovers_cargo_workspace_metadata() { std::fs::write( dir.path().join("Cargo.toml"), format!( - "[workspace]\nmembers = []\n[workspace.metadata.code-ranker]\nversion = \"{}\"\n[workspace.metadata.code-ranker.rules.thresholds.file]\nhk = 7\n", + "[workspace]\nmembers = []\n[workspace.metadata.code-ranker]\nversion = \"{}\"\n[workspace.metadata.code-ranker.plugins.base.rules.thresholds.file]\nhk = 7\n", code_ranker_graph::version::CONFIG_VERSION ), ) .unwrap(); let loaded = load(dir.path(), &[], &[], &[], &[]).unwrap(); - assert_eq!(loaded.config.rules.thresholds.file.get("hk"), Some(7.0)); + assert_eq!( + loaded + .config + .language_config("base") + .unwrap() + .rules + .thresholds + .file + .get("hk"), + Some(7.0) + ); let src = loaded.source_file.expect("discovered source file"); assert!(src.ends_with("#metadata.code-ranker"), "{src}"); } @@ -173,10 +205,11 @@ fn cli_override_sets_cycle_and_threshold() { &["file.cognitive=25".into(), "file.hk=1000".into()], ) .unwrap(); - assert_eq!(cfg.rules.cycles.chain, CycleRule::Max(0)); - assert_eq!(cfg.rules.cycles.mutual, CycleRule::Off); - assert_eq!(cfg.rules.thresholds.file.get("cognitive"), Some(25.0)); - assert_eq!(cfg.rules.thresholds.file.get("hk"), Some(1000.0)); + let lc = cfg.language_config("base").unwrap(); + assert_eq!(lc.rules.cycles.chain, CycleRule::Max(0)); + assert_eq!(lc.rules.cycles.mutual, CycleRule::Off); + assert_eq!(lc.rules.thresholds.file.get("cognitive"), Some(25.0)); + assert_eq!(lc.rules.thresholds.file.get("hk"), Some(1000.0)); } #[test] @@ -185,7 +218,7 @@ fn inline_overrides_set_each_key() { apply_inline_overrides( &mut cfg, &[ - "plugin=rust", + "plugins=rust,markdown", "ignore.tests=on", "ignore.dev_only_crates=true", "ignore.paths=a/**, b/**", @@ -200,17 +233,18 @@ fn inline_overrides_set_each_key() { ], ) .unwrap(); - assert_eq!(cfg.plugin.as_deref(), Some("rust")); - assert!(cfg.ignore.tests && cfg.ignore.dev_only_crates); - assert_eq!(cfg.ignore.paths, ["a/**", "b/**"]); + assert_eq!(cfg.plugins.enabled, vec!["rust", "markdown"]); + let lc = cfg.language_config("base").unwrap(); + assert!(lc.ignore.tests && lc.ignore.dev_only_crates); + assert_eq!(lc.ignore.paths, ["a/**", "b/**"]); assert_eq!(cfg.output.json.path.as_deref(), Some("out.json")); assert_eq!(cfg.output.html.path.as_deref(), Some("out.html")); assert_eq!(cfg.output.json.enabled, Some(false)); assert_eq!(cfg.output.html.enabled, Some(true)); - assert_eq!(cfg.rules.cycles.chain, CycleRule::Max(7)); - assert_eq!(cfg.rules.thresholds.file.get("loc"), Some(800.0)); - assert_eq!(cfg.rules.thresholds.file.get("sloc"), Some(1200.0)); - assert!(cfg.levels.functions); + assert_eq!(lc.rules.cycles.chain, CycleRule::Max(7)); + assert_eq!(lc.rules.thresholds.file.get("loc"), Some(800.0)); + assert_eq!(lc.rules.thresholds.file.get("sloc"), Some(1200.0)); + assert!(lc.levels.functions); } #[test] @@ -229,10 +263,11 @@ fn inline_overrides_set_template_and_remaining_ignore_keys() { ], ) .unwrap(); - assert!(!cfg.ignore.tests); - assert!(!cfg.ignore.gitignore); - assert!(!cfg.ignore.ignore_files); - assert!(!cfg.ignore.hidden); + let lc = cfg.language_config("base").unwrap(); + assert!(!lc.ignore.tests); + assert!(!lc.ignore.gitignore); + assert!(!lc.ignore.ignore_files); + assert!(!lc.ignore.hidden); assert_eq!(cfg.templates.prompt.as_deref(), Some("my-prompt.md")); let rust = cfg.templates.languages.get("rust").unwrap(); assert_eq!(rust.get("SRP").map(String::as_str), Some("docs/srp.md")); @@ -276,47 +311,83 @@ fn parse_threshold_path_shape() { #[test] fn set_metric_records_every_key() { - // `set_metric` only records the limit now — validity is checked later by + // `MetricThresholds::set` records the limit; validation is deferred to // `validate_thresholds` (which can see the project's custom metrics). let mut b = MetricThresholds::default(); for m in ["hk", "cyclomatic", "sloc", "mi", "bugs", "bogus"] { - set_metric(&mut b, m, 1.0).unwrap(); + b.set(m.into(), 1.0); assert_eq!(b.get(m), Some(1.0)); } } #[test] fn validate_thresholds_accepts_registry_and_custom_keys() { - use code_ranker_graph::MetricDef; - // A registry metric is always valid. let mut cfg = Config::default(); - cfg.rules.thresholds.file.set("hk".into(), 1.0); + // Write `hk` threshold into the [plugins.base] raw table. + let base = cfg.plugins.languages.entry("base".to_string()).or_default(); + let mut thr = toml::Table::new(); + thr.insert("hk".to_string(), toml::Value::Integer(1)); + let mut rules = toml::Table::new(); + let mut thresholds = toml::Table::new(); + thresholds.insert("file".to_string(), toml::Value::Table(thr)); + rules.insert("thresholds".to_string(), toml::Value::Table(thresholds)); + base.insert("rules".to_string(), toml::Value::Table(rules)); assert!(validate_thresholds(&cfg).is_ok()); // An unknown key with no matching custom metric is rejected, named. - cfg.rules.thresholds.file.set("tsr".into(), 1.0); + let base = cfg.plugins.languages.get_mut("base").unwrap(); + let thr_table = base + .get_mut("rules") + .and_then(|v| v.as_table_mut()) + .and_then(|t| t.get_mut("thresholds")) + .and_then(|v| v.as_table_mut()) + .and_then(|t| t.get_mut("file")) + .and_then(|v| v.as_table_mut()) + .unwrap(); + thr_table.insert("tsr".to_string(), toml::Value::Integer(1)); let err = validate_thresholds(&cfg).unwrap_err().to_string(); assert!(err.contains("tsr"), "names the bad key: {err}"); - // …but once `[metrics.tsr]` exists, the same threshold is accepted. - cfg.metrics.insert( - "tsr".into(), - MetricDef { - formula_cel: "1.0".into(), - ..Default::default() - }, + // Once `[plugins.base.metrics.tsr]` exists, the same threshold is accepted. + let base = cfg.plugins.languages.get_mut("base").unwrap(); + // Build a minimal MetricDef raw table (only formula_cel is required). + let mut def_table = toml::Table::new(); + def_table.insert( + "formula_cel".to_string(), + toml::Value::String("1.0".to_string()), ); + let metrics = base + .entry("metrics".to_string()) + .or_insert_with(|| toml::Value::Table(toml::Table::new())) + .as_table_mut() + .unwrap(); + metrics.insert("tsr".to_string(), toml::Value::Table(def_table)); assert!(validate_thresholds(&cfg).is_ok()); } #[test] fn set_threshold_and_cycle_reject_unknowns() { + // `parse_threshold_path` rejects an unknown scope. + assert!(parse_threshold_path("function.loc").is_err()); + assert!(parse_threshold_path("file.hk").is_ok()); + + // `apply_cli_overrides` rejects an unknown cycle kind. let mut cfg = Config::default(); - assert!(set_threshold(&mut cfg, "function", "loc", 1.0).is_err()); - set_threshold(&mut cfg, "file", "hk", 5.0).unwrap(); - assert_eq!(cfg.rules.thresholds.file.get("hk"), Some(5.0)); - assert!(set_cycle(&mut cfg, "weird", CycleRule::Off).is_err()); + assert!(apply_cli_overrides(&mut cfg, &[], &["weird=off".into()], &[]).is_err()); + + // A valid threshold override round-trips through the raw table. + let mut cfg2 = Config::default(); + apply_cli_overrides(&mut cfg2, &[], &[], &["file.hk=5".into()]).unwrap(); + assert_eq!( + cfg2.language_config("base") + .unwrap() + .rules + .thresholds + .file + .get("hk"), + Some(5.0) + ); } #[test] @@ -324,3 +395,208 @@ fn split_kv_requires_equals() { assert_eq!(split_kv("a=b", "x").unwrap(), ("a", "b")); assert!(split_kv("noeq", "x").is_err()); } + +/// The legacy `plugin = "x"` key must hard-error with a migration message. +#[test] +fn load_hard_errors_on_legacy_plugin_key() { + let dir = tempfile::tempdir().unwrap(); + let cfg_path = dir.path().join("code-ranker.toml"); + std::fs::write(&cfg_path, v("plugin = \"rust\"\n")).unwrap(); + let err = load(dir.path(), &[cfg_path.display().to_string()], &[], &[], &[]) + .unwrap_err() + .to_string(); + assert!( + err.contains("plugins = [") || err.contains("plugin = "), + "should mention migration to plugins = [...]: {err}" + ); +} + +/// `--config plugins..=value` writes into Config.plugins.languages. +#[test] +fn inline_override_sets_language_key() { + let mut cfg = Config::default(); + apply_inline_overrides( + &mut cfg, + &[ + "plugins.rust.skip_dirs=target,vendor", + "plugins.base.tests=false", + ], + ) + .unwrap(); + // rust entry should have skip_dirs as an array. + let rust = cfg.plugins.languages.get("rust").expect("rust entry"); + let skip = rust.get("skip_dirs").and_then(|v| v.as_array()).unwrap(); + assert!(skip.iter().any(|v| v.as_str() == Some("target"))); + assert!(skip.iter().any(|v| v.as_str() == Some("vendor"))); + // base entry has a boolean for tests (scalar after parse_leaf_value). + let base = cfg.plugins.languages.get("base").expect("base entry"); + assert!(base.contains_key("tests")); +} + +/// An alias-named block (`[plugins.javascript]`) is folded into its canonical +/// block (`[plugins.js]`) by deep-merge, so keys from both survive under the +/// canonical key. +#[test] +fn load_deep_merges_alias_block_into_canonical() { + let dir = tempfile::tempdir().unwrap(); + let cfg_path = dir.path().join("code-ranker.toml"); + std::fs::write( + &cfg_path, + v("[plugins.js]\nlevels.functions = true\n[plugins.javascript]\nignore.tests = false\n"), + ) + .unwrap(); + let loaded = load(dir.path(), &[cfg_path.display().to_string()], &[], &[], &[]).unwrap(); + assert!( + loaded.config.plugins.languages.contains_key("js") + && !loaded.config.plugins.languages.contains_key("javascript"), + "both blocks live under the canonical key" + ); + let lc = loaded.config.language_config("js").unwrap(); + assert!(lc.levels.functions, "key from the canonical block kept"); + assert!(!lc.ignore.tests, "key from the alias block merged in"); +} + +/// `--ignore ` extends the base layer's ignore globs (the `ensure_array` +/// path that creates `[plugins.base].ignore.paths` on first use). +#[test] +fn cli_override_extends_ignore_paths() { + let mut cfg = Config::default(); + apply_cli_overrides(&mut cfg, &["foo/**".into(), "bar/**".into()], &[], &[]).unwrap(); + let lc = cfg.language_config("base").unwrap(); + assert_eq!(lc.ignore.paths, ["foo/**", "bar/**"]); +} + +/// `--ignore` coerces a pre-existing conflicting base value into the array it +/// needs: a scalar `ignore.paths` leaf, or a scalar `ignore` standing where the +/// table belongs (the defensive replace arms of `ensure_array`). +#[test] +fn cli_ignore_coerces_conflicting_base_values() { + // Leaf conflict: base already holds `ignore.paths` as a string. + let mut cfg = Config::default(); + { + let mut ignore = toml::Table::new(); + ignore.insert("paths".into(), toml::Value::String("oops".into())); + cfg.plugins + .languages + .entry("base".into()) + .or_default() + .insert("ignore".into(), toml::Value::Table(ignore)); + } + apply_cli_overrides(&mut cfg, &["a/**".into()], &[], &[]).unwrap(); + assert_eq!( + cfg.language_config("base").unwrap().ignore.paths, + ["a/**"], + "scalar leaf replaced by an array" + ); + + // Intermediate conflict: base holds `ignore` itself as a scalar. + let mut cfg = Config::default(); + cfg.plugins + .languages + .entry("base".into()) + .or_default() + .insert("ignore".into(), toml::Value::Integer(5)); + apply_cli_overrides(&mut cfg, &["b/**".into()], &[], &[]).unwrap(); + assert_eq!( + cfg.language_config("base").unwrap().ignore.paths, + ["b/**"], + "scalar `ignore` replaced by a table" + ); +} + +/// A non-zero cycle budget is stored as a raw integer (`cycle_value`'s `Max(n)` +/// arm), and a fractional threshold as a raw float (`number_value`'s float arm). +#[test] +fn cli_override_cycle_budget_and_fractional_threshold() { + let mut cfg = Config::default(); + apply_cli_overrides(&mut cfg, &[], &["chain=5".into()], &["file.hk=2.5".into()]).unwrap(); + let lc = cfg.language_config("base").unwrap(); + assert_eq!( + lc.rules.cycles.chain, + CycleRule::Max(5), + "integer budget kept" + ); + assert_eq!(lc.rules.thresholds.file.get("hk"), Some(2.5), "float kept"); +} + +/// `parse_leaf_value` coerces leaf scalars by shape: a bare float → TOML float, and +/// a suffixed/garbage scalar (no decimal, not an int) → TOML string. +#[test] +fn inline_leaf_value_float_and_string_fallback() { + let mut cfg = Config::default(); + apply_inline_overrides( + &mut cfg, + &["plugins.rust.ratio=1.5", "plugins.rust.budget=8K"], + ) + .unwrap(); + let rust = cfg.plugins.languages.get("rust").unwrap(); + assert_eq!(rust.get("ratio").and_then(|v| v.as_float()), Some(1.5)); + assert_eq!( + rust.get("budget").and_then(|v| v.as_str()), + Some("8K"), + "suffixed scalar stays a string for the threshold deserializer" + ); +} + +/// A `plugins.` key with no `.` segment is a fatal, actionable error. +#[test] +fn inline_plugins_key_requires_path_segment() { + let mut cfg = Config::default(); + let err = apply_inline_overrides(&mut cfg, &["plugins.rust=x"]) + .unwrap_err() + .to_string(); + assert!(err.contains("plugins.."), "{err}"); +} + +/// `set_path` replaces a scalar standing where a sub-table is needed: writing +/// `a.b` after `a` already holds an integer turns `a` into a table. +#[test] +fn inline_leaf_override_replaces_scalar_with_table() { + let mut cfg = Config::default(); + apply_inline_overrides(&mut cfg, &["plugins.rust.a=1", "plugins.rust.a.b=2"]).unwrap(); + let rust = cfg.plugins.languages.get("rust").unwrap(); + let a = rust + .get("a") + .and_then(|v| v.as_table()) + .expect("a is now a table"); + assert_eq!(a.get("b").and_then(|v| v.as_integer()), Some(2)); +} + +/// `validate_thresholds` accepts a metric defined in the same language block. +#[test] +fn validate_thresholds_accepts_language_metrics_key() { + let mut cfg = Config::default(); + // Write the threshold for `custom_lang_metric` into [plugins.rust]. + let rust = cfg.plugins.languages.entry("rust".to_string()).or_default(); + let mut thr = toml::Table::new(); + thr.insert("custom_lang_metric".to_string(), toml::Value::Integer(1)); + let mut rules = toml::Table::new(); + let mut thresholds = toml::Table::new(); + thresholds.insert("file".to_string(), toml::Value::Table(thr)); + rules.insert("thresholds".to_string(), toml::Value::Table(thresholds)); + rust.insert("rules".to_string(), toml::Value::Table(rules)); + // Not in any metrics yet → rejected when validating the `rust` language. + assert!(validate_thresholds(&cfg).is_err()); + // Add `custom_lang_metric` to [plugins.rust].metrics → accepted because + // `language_config("rust")` merges [plugins.base] ⊕ [plugins.rust] and + // therefore sees both the threshold and the metric definition. + let mut metrics_table = toml::Table::new(); + let mut metric_def = toml::Table::new(); + metric_def.insert( + "formula_cel".to_string(), + toml::Value::String("1.0".to_string()), + ); + metrics_table.insert( + "custom_lang_metric".to_string(), + toml::Value::Table(metric_def), + ); + cfg.plugins + .languages + .get_mut("rust") + .unwrap() + .insert("metrics".to_string(), toml::Value::Table(metrics_table)); + assert!( + validate_thresholds(&cfg).is_ok(), + "metric in [plugins.rust].metrics should be a valid threshold key" + ); +} diff --git a/crates/code-ranker-cli/src/config/mod.rs b/crates/code-ranker-cli/src/config/mod.rs index 1487d0c2..faef3620 100644 --- a/crates/code-ranker-cli/src/config/mod.rs +++ b/crates/code-ranker-cli/src/config/mod.rs @@ -9,6 +9,7 @@ pub mod load; pub mod metrics; pub mod model; pub mod rules; +pub mod thresholds; pub mod violations; pub use ignore::apply_ignore; @@ -16,4 +17,4 @@ pub use load::load; pub(crate) use model::merge_project_principles; pub use model::{CycleRules, OutputArtifact, OutputConfig, RulesConfig, TemplatesConfig}; pub use rules::{apply_cycle_rules, rule_doc, rule_tuning}; -pub use violations::{Violation, check_violations}; +pub use violations::{Violation, check_violations_all}; diff --git a/crates/code-ranker-cli/src/config/model.rs b/crates/code-ranker-cli/src/config/model.rs index 72fe47d1..48cf1c69 100644 --- a/crates/code-ranker-cli/src/config/model.rs +++ b/crates/code-ranker-cli/src/config/model.rs @@ -39,49 +39,70 @@ pub const CONFIG_SCHEMA_VERSION: &str = code_ranker_graph::version::CONFIG_VERSI #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct Config { - /// Config-schema version (`major.minor`, e.g. `"4.0"`) — **required** in a + /// Config-schema version (`major.minor`, e.g. `"5.0"`) — **required** in a /// `code-ranker.toml`. Validated against [`CONFIG_SCHEMA_VERSION`] at load. /// `Option` so a missing value yields our migrate-hint error, not serde's. #[serde(default)] pub version: Option, - /// Default plugin name (e.g. "rust", "python"). Overridden by --plugin. + /// The `[plugins]` table: the active-language list (`enabled = [...]`) plus the + /// per-language config blocks (`[plugins.]`, with the shared `[plugins.base]`). #[serde(default)] - pub plugin: Option, - #[serde(default)] - pub ignore: IgnoreConfig, - #[serde(default)] - pub rules: RulesConfig, + pub plugins: PluginsConfig, + /// Output artifacts (`[output]`) — **global** (one report per run covers every + /// language), so this is not per-language. #[serde(default)] pub output: OutputConfig, - /// User-defined declarative metrics (`[metrics.]`): a CEL `formula_cel` plus - /// optional spec fields. Computed per node at snapshot time and emitted like - /// any built-in metric. Empty by default — absent → no change to output. + /// Per-file doc-corpus overrides (`[templates.languages..]`): use a + /// file from disk in place of the embedded `languages//.md`. Global. #[serde(default)] - pub metrics: BTreeMap, - /// Optional analysis levels (`[levels]`). Off by default → only the `files` - /// level is emitted, so default output is unchanged. + pub templates: TemplatesConfig, +} + +/// The `[plugins]` table. `enabled` is the active-language list; every other key is +/// a per-language config block (`[plugins.]`), with the reserved `"base"` key +/// inherited by every language. `enabled` and `base` are therefore reserved and +/// cannot be language names. The blocks are free-form: plugin-config keys +/// (`extensions`, `detect_markers`, …) are consumed via `effective_plugin_config`, +/// while the orchestrator sections (`ignore`/`rules`/`metrics`/`levels`/`report`/ +/// `principles`) are read via [`Config::language_config`]. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct PluginsConfig { + /// Active languages (e.g. `["rust", "markdown"]`). Empty → auto-detect all. + /// Overridden by `--plugins`. #[serde(default)] + pub enabled: Vec, + /// Per-language config blocks keyed by language (plus the reserved `"base"`). + /// Captures every `[plugins]` key other than `enabled`. + #[serde(flatten)] + pub languages: BTreeMap, +} + +/// The orchestrator-read config sections that are now **per-language**, resolved +/// for one language by [`Config::language_config`] (defaults' `[plugins.base]` ⊕ +/// user `[plugins.base]` ⊕ user `[plugins.]`). Plugin-config keys in the same +/// block (`extensions`, …) are ignored here — they feed the plugin separately. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct LangConfig { + pub ignore: IgnoreConfig, + pub rules: RulesConfig, + pub metrics: BTreeMap, pub levels: LevelsConfig, - /// Project-level report-list patches (`[report]`): `columns` / `card` / - /// `stats`, each a list-override (plain array = replace, or an op-table - /// `{add,remove,replace,clear,prepend}`). Applied over the language's own - /// `[report]` patch, so a project can surface its custom metrics in the table - /// / card / JSON stats. Raw table; parsed by `list_override::report_override_section`. - #[serde(default)] pub report: toml::Table, - /// Project-defined Prompt-Generator principles (`[principles.]`), keyed by the - /// principle id. Appended to the active plugin's catalog (a same-id project principle - /// overrides the plugin's), so a project can recommend/scorecard on its own - /// custom metric. Empty by default — absent → no change to output. - #[serde(default)] pub principles: BTreeMap, - /// Per-file doc-corpus overrides (`[templates.languages..]`): use a - /// file from disk in place of the embedded `languages//.md`. Empty by - /// default — absent → the embedded corpus is used unchanged. - #[serde(default)] - pub templates: TemplatesConfig, } +/// The orchestrator config-section keys carried inside a `[plugins.]` block. +/// (Other keys in the block are plugin config, consumed via `effective_plugin_config`.) +pub(crate) const LANG_SECTION_KEYS: &[&str] = &[ + "ignore", + "rules", + "metrics", + "levels", + "report", + "principles", +]; + /// Doc-corpus override map (`[templates.languages..]`): `lang → (ID → /// file path)`. A configured path is read from disk in place of the embedded /// `languages//.md` (see `crate::templates`). Empty by default — its @@ -184,9 +205,8 @@ pub struct OutputConfig { pub html: OutputArtifact, pub sarif: OutputArtifact, pub codequality: OutputArtifact, - /// `prompt` / `scorecard` are flag-driven (off unless `--output.` is - /// passed); their `path` here only supplies the default destination template. - pub prompt: OutputArtifact, + /// `scorecard` is flag-driven (off unless `--output.scorecard` is passed); its + /// `path` here only supplies the default destination template. pub scorecard: OutputArtifact, } @@ -338,7 +358,9 @@ impl MetricThresholds { pub fn get(&self, metric: &str) -> Option { self.limits.get(metric).copied() } - /// Set (or override) the limit for `metric`. + /// Set (or override) the limit for `metric`. Used by tests; overrides now write + /// raw `[plugins.]` tables rather than mutating typed thresholds. + #[allow(dead_code)] pub fn set(&mut self, metric: String, limit: f64) { self.limits.insert(metric, limit); } @@ -417,92 +439,6 @@ pub(crate) fn parse_number(s: &str) -> Result { Ok(n * mult) } -/// TOML rejects a bare `300K` (a `K`/`M`/`G` suffix makes it neither a number nor -/// a string), so without help a user must write `hk = "300K"`. This pre-pass lets -/// them write `hk = 300K` by quoting bare suffixed numbers **only inside a -/// `*thresholds*` table**, before the text reaches the TOML parser. Plain and -/// underscored integers stay native; already-quoted values and everything outside -/// a thresholds table are left untouched. The matching CLI form (`--threshold -/// file.hk=300K`) needs no help — it goes straight through [`parse_number`]. -pub(crate) fn quote_suffixed_thresholds(text: &str) -> String { - let mut out = String::with_capacity(text.len() + 16); - let mut in_thresholds = false; - for line in text.lines() { - let trimmed = line.trim_start(); - if trimmed.starts_with('[') { - // Section header (`[t]` or `[[t]]`): a thresholds table enables quoting. - let name = trimmed.trim_start_matches('['); - in_thresholds = name - .split(']') - .next() - .is_some_and(|s| s.contains("thresholds")); - } else if in_thresholds && let Some(quoted) = quote_suffixed_value_line(line) { - out.push_str("ed); - out.push('\n'); - continue; - } - out.push_str(line); - out.push('\n'); - } - out -} - -/// If `line` is a `key = ` assignment, return it with the -/// value quoted (formatting and any trailing comment preserved); else `None`. -fn quote_suffixed_value_line(line: &str) -> Option { - let eq = line.find('=')?; - let key = line[..eq].trim(); - if key.is_empty() - || !key - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') - { - return None; - } - let after = &line[eq + 1..]; - let (val_seg, comment) = match after.find('#') { - Some(h) => after.split_at(h), - None => (after, ""), - }; - if !is_bare_suffixed_number(val_seg.trim()) { - return None; - } - let lead: String = val_seg.chars().take_while(|c| c.is_whitespace()).collect(); - let trail: String = val_seg - .chars() - .rev() - .take_while(|c| c.is_whitespace()) - .collect(); - Some(format!( - "{}={lead}\"{}\"{trail}{comment}", - &line[..eq], - val_seg.trim() - )) -} - -/// Does `v` look like a bare `K`/`M`/`G`-suffixed number (`300K`, `1.5M`, -/// `5_000K`)? Already-quoted values and plain numbers return `false`. -fn is_bare_suffixed_number(v: &str) -> bool { - let Some(last) = v.chars().last() else { - return false; - }; - if !matches!(last, 'k' | 'K' | 'm' | 'M' | 'g' | 'G') { - return false; - } - let body = &v[..v.len() - 1]; - let mut seen_digit = false; - let mut seen_dot = false; - for c in body.chars() { - match c { - '0'..='9' => seen_digit = true, - '_' => {} - '.' if !seen_dot => seen_dot = true, - _ => return false, - } - } - seen_digit -} - #[cfg(test)] #[path = "model_test.rs"] mod tests; diff --git a/crates/code-ranker-cli/src/config/model_test.rs b/crates/code-ranker-cli/src/config/model_test.rs index c4b05a4d..0cb43db9 100644 --- a/crates/code-ranker-cli/src/config/model_test.rs +++ b/crates/code-ranker-cli/src/config/model_test.rs @@ -1,5 +1,35 @@ use super::*; +/// The headline per-language feature: `[plugins.]` overrides `[plugins.base]` +/// for that language, while a language without its own block inherits the base. +#[test] +fn language_config_overrides_base_per_language() { + let cfg: Config = toml::from_str( + "[plugins.base.rules.thresholds.file]\n\ + hk = 350000\n\ + [plugins.base.ignore]\n\ + tests = true\n\ + [plugins.rust.rules.thresholds.file]\n\ + hk = 400000\n\ + [plugins.rust.ignore]\n\ + tests = false\n", + ) + .unwrap(); + + // rust: its own block wins over the base. + let rust = cfg.language_config("rust").unwrap(); + assert_eq!(rust.rules.thresholds.file.get("hk"), Some(400000.0)); + assert!(!rust.ignore.tests, "rust overrides ignore.tests = false"); + + // markdown: no block of its own → inherits the base. + let md = cfg.language_config("markdown").unwrap(); + assert_eq!(md.rules.thresholds.file.get("hk"), Some(350000.0)); + assert!( + md.ignore.tests, + "markdown inherits base ignore.tests = true" + ); +} + #[test] fn cycle_rules_effective_default_is_strict() { // The trivial `CycleRules::default()` is a serde filler (`Off`/`Off`); the @@ -8,12 +38,12 @@ fn cycle_rules_effective_default_is_strict() { let trivial = CycleRules::default(); assert!(trivial.mutual.is_off() && trivial.chain.is_off()); - let d = Config::default().rules.cycles; - assert_eq!(d.mutual, CycleRule::Max(0)); - assert_eq!(d.chain, CycleRule::Max(0)); - assert_eq!(d.budget_for("mutual"), Some(0)); - assert_eq!(d.budget_for("chain"), Some(0)); - assert_eq!(d.budget_for("unknown"), None); + let d = Config::default().language_config("base").unwrap(); + assert_eq!(d.rules.cycles.mutual, CycleRule::Max(0)); + assert_eq!(d.rules.cycles.chain, CycleRule::Max(0)); + assert_eq!(d.rules.cycles.budget_for("mutual"), Some(0)); + assert_eq!(d.rules.cycles.budget_for("chain"), Some(0)); + assert_eq!(d.rules.cycles.budget_for("unknown"), None); } #[test] @@ -23,20 +53,25 @@ fn builtin_defaults_complete() { // back to a section's `Default` (which re-enters the `BUILTIN` LazyLock). // Spot-check the values the rest of the code relies on as "the defaults". let d = Config::default(); - assert!(d.ignore.tests && d.ignore.gitignore && d.ignore.ignore_files && d.ignore.hidden); - assert!(!d.ignore.dev_only_crates && d.ignore.paths.is_empty()); - assert_eq!(d.rules.cycles.mutual, CycleRule::Max(0)); - assert_eq!(d.rules.cycles.chain, CycleRule::Max(0)); - assert!(d.rules.thresholds.file.limits.is_empty()); - assert!(!d.levels.functions); + let lc = d.language_config("base").unwrap(); + assert!(lc.ignore.tests && lc.ignore.gitignore && lc.ignore.ignore_files && lc.ignore.hidden); + assert!(!lc.ignore.dev_only_crates && lc.ignore.paths.is_empty()); + assert_eq!(lc.rules.cycles.mutual, CycleRule::Max(0)); + assert_eq!(lc.rules.cycles.chain, CycleRule::Max(0)); + assert!(lc.rules.thresholds.file.limits.is_empty()); + assert!(!lc.levels.functions); // Every output format has a default path; json/html are on, sarif/cq off. assert!(d.output.json.path.is_some() && d.output.json.enabled == Some(true)); assert!(d.output.html.path.is_some() && d.output.html.enabled == Some(true)); assert!(d.output.sarif.path.is_some() && d.output.sarif.enabled == Some(false)); assert!(d.output.codequality.path.is_some() && d.output.codequality.enabled == Some(false)); - assert!(d.output.prompt.path.is_some() && d.output.scorecard.path.as_deref() == Some("stdout")); - // No project plugin pinned by default (→ auto detection). - assert!(d.plugin.is_none()); + assert!(d.output.scorecard.path.as_deref() == Some("stdout")); + // No project plugins pinned by default (→ auto-detect all markers). + assert!(d.plugins.enabled.is_empty()); + // The built-in defaults live in the raw [plugins.base] block: `language_config` + // is the right way to read them (tested above). There is no user-added language + // key beyond "base" in the pristine defaults. + assert!(!d.plugins.languages.contains_key("rust")); } #[test] @@ -56,23 +91,24 @@ fn parse_number_handles_separators_and_suffixes() { #[test] fn config_toml_parses_cycles_and_thresholds() { let src = " -[rules.cycles] +[plugins.base.rules.cycles] mutual = true chain = 7 -[rules.thresholds.file] +[plugins.base.rules.thresholds.file] loc = 800 sloc = 1_200 cyclomatic = 25 mi = \"5K\" "; let cfg: Config = toml::from_str(src).unwrap(); - assert_eq!(cfg.rules.cycles.mutual, CycleRule::Max(0)); - assert_eq!(cfg.rules.cycles.chain, CycleRule::Max(7)); - assert_eq!(cfg.rules.thresholds.file.get("loc"), Some(800.0)); + let lc = cfg.language_config("base").unwrap(); + assert_eq!(lc.rules.cycles.mutual, CycleRule::Max(0)); + assert_eq!(lc.rules.cycles.chain, CycleRule::Max(7)); + assert_eq!(lc.rules.thresholds.file.get("loc"), Some(800.0)); // `sloc` (and every other engine metric) is now accepted, not just `loc`. - assert_eq!(cfg.rules.thresholds.file.get("sloc"), Some(1_200.0)); - assert_eq!(cfg.rules.thresholds.file.get("cyclomatic"), Some(25.0)); - assert_eq!(cfg.rules.thresholds.file.get("mi"), Some(5_000.0)); + assert_eq!(lc.rules.thresholds.file.get("sloc"), Some(1_200.0)); + assert_eq!(lc.rules.thresholds.file.get("cyclomatic"), Some(25.0)); + assert_eq!(lc.rules.thresholds.file.get("mi"), Some(5_000.0)); } #[test] @@ -80,30 +116,19 @@ fn bare_suffixed_threshold_values_parse() { // TOML rejects a bare `300K`; the pre-pass quotes it (only inside a // thresholds table) so the config parses without the user adding quotes. let src = " -[rules.cycles] +[plugins.base.rules.cycles] mutual = true -[rules.thresholds.file] +[plugins.base.rules.thresholds.file] hk = 300K cyclomatic = 200 # plain int stays native sloc = 1.5M # fractional + suffix "; - let cfg: Config = toml::from_str("e_suffixed_thresholds(src)).unwrap(); - assert_eq!(cfg.rules.thresholds.file.get("hk"), Some(300_000.0)); - assert_eq!(cfg.rules.thresholds.file.get("cyclomatic"), Some(200.0)); - assert_eq!(cfg.rules.thresholds.file.get("sloc"), Some(1_500_000.0)); -} - -#[test] -fn suffix_quoting_is_scoped_to_thresholds_tables() { - // A bare-suffixed value outside a thresholds table is NOT touched (it would - // still be invalid TOML there — we only help where suffixes are meaningful). - let outside = quote_suffixed_thresholds("[other]\nx = 300K\n"); - assert!(outside.contains("x = 300K"), "untouched outside: {outside}"); - let inside = quote_suffixed_thresholds("[rules.thresholds.file]\nhk = 300K\n"); - assert!(inside.contains("hk = \"300K\""), "quoted inside: {inside}"); - // Already-quoted and plain values are left as-is. - let q = quote_suffixed_thresholds("[rules.thresholds.file]\na = \"5M\"\nb = 200\n"); - assert!(q.contains("a = \"5M\"") && q.contains("b = 200"), "{q}"); + let cfg: Config = + toml::from_str(&crate::config::thresholds::quote_suffixed_thresholds(src)).unwrap(); + let lc = cfg.language_config("base").unwrap(); + assert_eq!(lc.rules.thresholds.file.get("hk"), Some(300_000.0)); + assert_eq!(lc.rules.thresholds.file.get("cyclomatic"), Some(200.0)); + assert_eq!(lc.rules.thresholds.file.get("sloc"), Some(1_500_000.0)); } #[test] @@ -111,20 +136,23 @@ fn threshold_value_accepts_int_and_float() { // Exercises the per-value deserializer over both TOML scalar forms: an // integer (`visit_i64`) and a bare float (`visit_f64`). let cfg: Config = - toml::from_str("[rules.thresholds.file]\ncyclomatic = 30\nmi = 12.5\n").unwrap(); - assert_eq!(cfg.rules.thresholds.file.get("cyclomatic"), Some(30.0)); - assert_eq!(cfg.rules.thresholds.file.get("mi"), Some(12.5)); + toml::from_str("[plugins.base.rules.thresholds.file]\ncyclomatic = 30\nmi = 12.5\n") + .unwrap(); + let lc = cfg.language_config("base").unwrap(); + assert_eq!(lc.rules.thresholds.file.get("cyclomatic"), Some(30.0)); + assert_eq!(lc.rules.thresholds.file.get("mi"), Some(12.5)); } #[test] fn project_principle_parses_with_id_defaults() { - // `[principles.TSR]` keys the principle by its table name; `label`/`title` - // default to the id, so a minimal entry needs only `sort_metric`. + // `[plugins.base.principles.TSR]` keys the principle by its table name; + // `label`/`title` default to the id, so a minimal entry needs only `sort_metric`. let cfg = toml::from_str::( - "[principles.TSR]\nsort_metric = \"tsr\"\nprompt = \"fix the ratio\"\n", + "[plugins.base.principles.TSR]\nsort_metric = \"tsr\"\nprompt = \"fix the ratio\"\n", ) .unwrap(); - let def = &cfg.principles["TSR"]; + let lc = cfg.language_config("base").unwrap(); + let def = &lc.principles["TSR"]; let p = def.to_principle("TSR"); assert_eq!(p.id, "TSR"); assert_eq!(p.label, "TSR"); @@ -138,8 +166,10 @@ fn threshold_keys_parse_without_validation() { // Deserialization records every key verbatim — a custom `[metrics.]` // is invisible here, so validation is deferred to `load` (see // `super::load::validate_thresholds`). A mistyped key is caught there. - let cfg = toml::from_str::("[rules.thresholds.file]\nslocc = 800\n").unwrap(); - assert_eq!(cfg.rules.thresholds.file.get("slocc"), Some(800.0)); + let cfg = + toml::from_str::("[plugins.base.rules.thresholds.file]\nslocc = 800\n").unwrap(); + let lc = cfg.language_config("base").unwrap(); + assert_eq!(lc.rules.thresholds.file.get("slocc"), Some(800.0)); } #[test] diff --git a/crates/code-ranker-cli/src/config/rules.rs b/crates/code-ranker-cli/src/config/rules.rs index 01aa635c..875675d1 100644 --- a/crates/code-ranker-cli/src/config/rules.rs +++ b/crates/code-ranker-cli/src/config/rules.rs @@ -47,6 +47,7 @@ pub struct RuleDoc { /// spec for its trailing metric key. `None` when no spec matches. pub fn rule_doc( id: &str, + lang: &str, node_attributes: &BTreeMap, cycle_kinds: &BTreeMap, ) -> Option { @@ -61,12 +62,13 @@ pub fn rule_doc( let metric = id.rsplit('.').next().unwrap_or(id); let s = node_attributes.get(metric)?; // A metric's `fix` is its own `remediation` when one is authored (a project - // `[metrics.]` may set a custom fix); otherwise auto-derive the pointer to - // its doc from the key, so the built-in catalog carries no duplicated boilerplate - // and the command always names the correct subject (`docs `). + // `[metrics.]` may set a custom fix); otherwise auto-derive a command that + // generates the AI fix-prompt for this metric, so the built-in catalog carries no + // duplicated boilerplate and the command always names the correct subject + // (`report --plugins --prompt `). let fix = s.remediation.clone().or_else(|| { Some(format!( - "Run `code-ranker docs {metric}` and follow its instructions." + "Run `code-ranker report --plugins {lang} --prompt {metric}` to generate an AI fix-prompt." )) }); Some(RuleDoc { @@ -89,13 +91,19 @@ pub fn rule_group(id: &str) -> &'static str { .unwrap_or("?") } -pub fn rule_tuning(id: &str) -> String { +pub fn rule_tuning(id: &str, lang: &str) -> String { + // Rules are per-language: tune one language under `[plugins.]`, or the + // shared `[plugins.base]` layer to affect every language at once. if let Some(kind) = id.strip_prefix("cycle.") { format!( - "disable with --cycle-rule {kind}=off · rules.cycles.{kind} in code-ranker.toml" + "set with --config plugins.{lang}.rules.cycles.{kind}=off · \ + plugins.{lang}.rules.cycles.{kind} in code-ranker.toml (or plugins.base for all)" ) } else if let Some(rest) = id.strip_prefix("threshold.") { - format!("set with --threshold {rest}=N · rules.thresholds.{rest} in code-ranker.toml") + format!( + "set with --config plugins.{lang}.rules.thresholds.{rest}=N · \ + plugins.{lang}.rules.thresholds.{rest} in code-ranker.toml (or plugins.base for all)" + ) } else { String::new() } diff --git a/crates/code-ranker-cli/src/config/rules_test.rs b/crates/code-ranker-cli/src/config/rules_test.rs index bdde63cc..c470a47b 100644 --- a/crates/code-ranker-cli/src/config/rules_test.rs +++ b/crates/code-ranker-cli/src/config/rules_test.rs @@ -20,16 +20,16 @@ fn rule_doc_resolves_why_fix_from_specs_and_cycle_kinds() { ); // A threshold id resolves to its metric's node-attribute spec. - let m = rule_doc("threshold.file.hk", &na, &ck).expect("metric doc"); + let m = rule_doc("threshold.file.hk", "rust", &na, &ck).expect("metric doc"); assert_eq!(m.title.as_deref(), Some("Henry–Kafura")); assert_eq!(m.why.as_deref(), Some("why-hk")); assert_eq!(m.fix.as_deref(), Some("fix-hk")); // A cycle id resolves to the cycle-kind spec. - let c = rule_doc("cycle.mutual", &na, &ck).expect("cycle doc"); + let c = rule_doc("cycle.mutual", "rust", &na, &ck).expect("cycle doc"); assert_eq!(c.why.as_deref(), Some("why-cyc")); assert_eq!(c.fix.as_deref(), Some("fix-cyc")); // An unknown metric has no spec → no doc. - assert!(rule_doc("threshold.file.bogus", &na, &ck).is_none()); + assert!(rule_doc("threshold.file.bogus", "rust", &na, &ck).is_none()); } #[test] @@ -37,16 +37,16 @@ fn rule_doc_auto_derives_fix_for_a_metric_without_remediation() { use code_ranker_plugin_api::attrs::ValueType; let mut na = BTreeMap::new(); // A built-in metric carries no boilerplate `remediation`; the `fix` line is - // derived from the key as a pointer to its `docs` page. + // derived from the key as a command that generates the AI fix-prompt. na.insert( "sloc".to_string(), AttributeSpec::new(ValueType::Int, "Source"), ); let ck = BTreeMap::new(); - let m = rule_doc("threshold.file.sloc", &na, &ck).expect("metric doc"); + let m = rule_doc("threshold.file.sloc", "rust", &na, &ck).expect("metric doc"); assert_eq!( m.fix.as_deref(), - Some("Run `code-ranker docs sloc` and follow its instructions.") + Some("Run `code-ranker report --plugins rust --prompt sloc` to generate an AI fix-prompt.") ); } @@ -109,7 +109,10 @@ fn apply_cycle_rules_clears_disabled_cycle_attr_on_nodes() { #[test] fn rule_tuning_emits_cli_and_config_hints() { - assert!(rule_tuning("cycle.mutual").contains("--cycle-rule mutual=off")); - assert!(rule_tuning("threshold.file.hk").contains("--threshold file.hk=N")); - assert_eq!(rule_tuning("bogus.id"), ""); + assert!(rule_tuning("cycle.mutual", "rust").contains("plugins.rust.rules.cycles.mutual=off")); + assert!( + rule_tuning("threshold.file.hk", "rust") + .contains("plugins.rust.rules.thresholds.file.hk=N") + ); + assert_eq!(rule_tuning("bogus.id", "rust"), ""); } diff --git a/crates/code-ranker-cli/src/config/thresholds.rs b/crates/code-ranker-cli/src/config/thresholds.rs new file mode 100644 index 00000000..5f8c34fc --- /dev/null +++ b/crates/code-ranker-cli/src/config/thresholds.rs @@ -0,0 +1,96 @@ +//! Threshold-literal pre-quoting: a text pass run on a `code-ranker.toml` before +//! the TOML parser sees it, so a bare `K`/`M`/`G`-suffixed threshold (`hk = 300K`) +//! is accepted. Separate from the data model ([`super::model`]) and the +//! value-parsing ([`super::model::parse_number`]) — this is purely a source-text +//! rewrite, depending on nothing but `std`. + +/// TOML rejects a bare `300K` (a `K`/`M`/`G` suffix makes it neither a number nor +/// a string), so without help a user must write `hk = "300K"`. This pre-pass lets +/// them write `hk = 300K` by quoting bare suffixed numbers **only inside a +/// `*thresholds*` table**, before the text reaches the TOML parser. Plain and +/// underscored integers stay native; already-quoted values and everything outside +/// a thresholds table are left untouched. The matching CLI form (`--threshold +/// file.hk=300K`) needs no help — it goes straight through +/// [`parse_number`](super::model::parse_number). +pub(crate) fn quote_suffixed_thresholds(text: &str) -> String { + let mut out = String::with_capacity(text.len() + 16); + let mut in_thresholds = false; + for line in text.lines() { + let trimmed = line.trim_start(); + if trimmed.starts_with('[') { + // Section header (`[t]` or `[[t]]`): a thresholds table enables quoting. + let name = trimmed.trim_start_matches('['); + in_thresholds = name + .split(']') + .next() + .is_some_and(|s| s.contains("thresholds")); + } else if in_thresholds && let Some(quoted) = quote_suffixed_value_line(line) { + out.push_str("ed); + out.push('\n'); + continue; + } + out.push_str(line); + out.push('\n'); + } + out +} + +/// If `line` is a `key = ` assignment, return it with the +/// value quoted (formatting and any trailing comment preserved); else `None`. +fn quote_suffixed_value_line(line: &str) -> Option { + let eq = line.find('=')?; + let key = line[..eq].trim(); + if key.is_empty() + || !key + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + return None; + } + let after = &line[eq + 1..]; + let (val_seg, comment) = match after.find('#') { + Some(h) => after.split_at(h), + None => (after, ""), + }; + if !is_bare_suffixed_number(val_seg.trim()) { + return None; + } + let lead: String = val_seg.chars().take_while(|c| c.is_whitespace()).collect(); + let trail: String = val_seg + .chars() + .rev() + .take_while(|c| c.is_whitespace()) + .collect(); + Some(format!( + "{}={lead}\"{}\"{trail}{comment}", + &line[..eq], + val_seg.trim() + )) +} + +/// Does `v` look like a bare `K`/`M`/`G`-suffixed number (`300K`, `1.5M`, +/// `5_000K`)? Already-quoted values and plain numbers return `false`. +fn is_bare_suffixed_number(v: &str) -> bool { + let Some(last) = v.chars().last() else { + return false; + }; + if !matches!(last, 'k' | 'K' | 'm' | 'M' | 'g' | 'G') { + return false; + } + let body = &v[..v.len() - 1]; + let mut seen_digit = false; + let mut seen_dot = false; + for c in body.chars() { + match c { + '0'..='9' => seen_digit = true, + '_' => {} + '.' if !seen_dot => seen_dot = true, + _ => return false, + } + } + seen_digit +} + +#[cfg(test)] +#[path = "thresholds_test.rs"] +mod tests; diff --git a/crates/code-ranker-cli/src/config/thresholds_test.rs b/crates/code-ranker-cli/src/config/thresholds_test.rs new file mode 100644 index 00000000..313e3db0 --- /dev/null +++ b/crates/code-ranker-cli/src/config/thresholds_test.rs @@ -0,0 +1,45 @@ +use super::*; + +#[test] +fn suffix_quoting_is_scoped_to_thresholds_tables() { + // A bare-suffixed value outside a thresholds table is NOT touched (it would + // still be invalid TOML there — we only help where suffixes are meaningful). + let outside = quote_suffixed_thresholds("[other]\nx = 300K\n"); + assert!(outside.contains("x = 300K"), "untouched outside: {outside}"); + let inside = quote_suffixed_thresholds("[plugins.base.rules.thresholds.file]\nhk = 300K\n"); + assert!(inside.contains("hk = \"300K\""), "quoted inside: {inside}"); + // Already-quoted and plain values are left as-is. + let q = + quote_suffixed_thresholds("[plugins.base.rules.thresholds.file]\na = \"5M\"\nb = 200\n"); + assert!(q.contains("a = \"5M\"") && q.contains("b = 200"), "{q}"); +} + +#[test] +fn quote_skips_malformed_keys_and_values() { + // Inside a thresholds table, only a clean `key = bare-suffixed-number` is + // rewritten; every other shape is passed through untouched. + let out = quote_suffixed_thresholds( + "[plugins.base.rules.thresholds.file]\n\ + a-b = 300K\n\ + bad key = 300K\n\ + weird = 1a2K\n\ + under = 5_000K\n\ + empty =\n", + ); + // Hyphenated key and an underscore-grouped body are valid → quoted. + assert!(out.contains("a-b = \"300K\""), "hyphen key quoted: {out}"); + assert!( + out.contains("under = \"5_000K\""), + "underscore body quoted: {out}" + ); + // Key with a space, a non-numeric body, and an empty value are all left bare. + assert!( + out.contains("bad key = 300K"), + "spaced key untouched: {out}" + ); + assert!( + out.contains("weird = 1a2K"), + "non-numeric body untouched: {out}" + ); + assert!(out.contains("empty =\n"), "empty value untouched: {out}"); +} diff --git a/crates/code-ranker-cli/src/config/violations.rs b/crates/code-ranker-cli/src/config/violations.rs index 4fb78f71..3d92fd0f 100644 --- a/crates/code-ranker-cli/src/config/violations.rs +++ b/crates/code-ranker-cli/src/config/violations.rs @@ -19,6 +19,9 @@ fn attr_num(node: &Node, key: &str) -> Option { #[derive(Debug, serde::Serialize)] pub struct Violation { + /// The language (plugin name) this violation belongs to. Stamped by + /// `check_violations_all`; empty when produced directly by `check_violations`. + pub language: String, pub rule: String, /// Concern-group code (`SIZ` / `CPL` / `CPX` / `CYC`, or a custom check's /// free-form label). A `String` because custom `[rules.checks]` carry their @@ -62,6 +65,27 @@ pub fn check_violations( vs } +/// Aggregate violations across all languages in the snapshot. Each language is +/// gated with ITS OWN rules (`rules_by_lang[lang]`, falling back to a default when +/// absent), and `language` is stamped on every returned violation so downstream +/// code can distinguish per-language findings. +pub fn check_violations_all( + languages: &std::collections::BTreeMap, + rules_by_lang: &std::collections::BTreeMap, +) -> Vec { + let mut all = Vec::new(); + let fallback = RulesConfig::default(); + for (lang, ls) in languages { + let rules = rules_by_lang.get(lang).unwrap_or(&fallback); + let mut vs = check_violations(&ls.graphs, rules); + for v in &mut vs { + v.language = lang.clone(); + } + all.extend(vs); + } + all +} + fn check_level_violations( name: &'static str, level: &LevelGraph, @@ -142,6 +166,7 @@ fn check_level_violations( /// metric breaches by `check`'s worst-first sort). fn push_check(vs: &mut Vec, graph: &'static str, location: String, hit: CheckHit) { vs.push(Violation { + language: String::new(), rule: format!("check.{}", hit.id), group: hit.group, graph, @@ -292,6 +317,7 @@ fn push( group: &str, ) { vs.push(Violation { + language: String::new(), rule: id.to_string(), group: group.to_string(), graph, diff --git a/crates/code-ranker-cli/src/config/violations_test.rs b/crates/code-ranker-cli/src/config/violations_test.rs index 677f0334..fb5af9d5 100644 --- a/crates/code-ranker-cli/src/config/violations_test.rs +++ b/crates/code-ranker-cli/src/config/violations_test.rs @@ -6,7 +6,10 @@ use code_ranker_graph::level_graph::CycleGroup; /// trivial `RulesConfig::default()` is now an empty serde filler, so tests that /// exercise the *shipped* default behaviour build it from `Config::default()`. fn strict_rules() -> RulesConfig { - crate::config::model::Config::default().rules + crate::config::model::Config::default() + .language_config("base") + .unwrap() + .rules } fn file_node(id: &str, attrs: &[(&str, AttrValue)]) -> Node { diff --git a/crates/code-ranker-cli/src/docs.rs b/crates/code-ranker-cli/src/docs.rs index efaa4927..84b979e8 100644 --- a/crates/code-ranker-cli/src/docs.rs +++ b/crates/code-ranker-cli/src/docs.rs @@ -1,27 +1,22 @@ -//! The `docs ` command: print a reference doc to stdout. No analysis — it -//! resolves the merged config (auto-discovered from the current directory) and the -//! language plugin, then builds the principle + metric + category specs from the -//! config and plugin (the same specs an analyzed snapshot carries, minus the graph). -//! A reference doc is **strictly per-language**: every subject but `ai` requires a -//! resolved plugin and fails (same diagnostic as `check` / `report`) when none does. -//! Subjects match separator/case-insensitively (`fan_in` = `Fan-in` = `FAN in`): +//! The `docs ` command: print a reference doc to stdout. No +//! analysis. Docs are **per-language**, so the language comes FIRST (a registered +//! plugin name, or `base` for the language-agnostic catalog). Forms: //! -//! - `ai` → the offline AI-agent playbook (resolved plugin → full playbook + catalog; -//! none → a brief intro + how to pick a plugin — the one subject that does not -//! hard-fail without a plugin); -//! - `metrics` / `principles` → an index of every metric / design principle; -//! - `` → that category (`loc`, `complexity`, …) + its member metrics; -//! - `` → its spec card (incl. language metrics like Rust's `unsafe`), plus -//! its prose doc when one exists; -//! - `` → its full doc (or a synthetic card for a doc-less custom one); -//! - anything else (or no subject) → a catalog of every subject. +//! - `docs` → list the project's detected languages + every documentable one; +//! - `docs ` → that language's full subject catalog; +//! - `docs ` → the doc for the subject. //! -//! Categories and metrics are read from the plugin's level specs + the central -//! catalog; principle ids and custom metrics declared in the project config -//! (`[principles.]` / `[metrics.]`) are first-class subjects too. +//! A `` given without a language errors and points at the per-language +//! form. `` is `ai` (the offline AI-agent playbook), `metrics` / +//! `principles` (an index of each), a `` (`loc`, `complexity`, …), a +//! `` (its spec card + prose doc), or a `` id. Subjects match +//! separator/case-insensitively (`fan_in` = `Fan-in` = `FAN in`). +//! +//! Specs are built from the language plugin's level specs + the central catalog; +//! `base` uses the neutral built-in catalog. Project `[plugins..principles]` +//! / `[plugins..metrics]` are first-class subjects too. use anyhow::{Result, bail}; -use code_ranker_graph::version::CONFIG_VERSION; use code_ranker_plugin_api::Principle; use code_ranker_plugin_api::level::{AttributeGroup, AttributeSpec}; use code_ranker_plugin_api::plugin::PluginInput; @@ -42,41 +37,56 @@ struct DocSpecs { templates: TemplatesConfig, } -/// Print the doc for `subject` (or the catalog when it is absent / unknown). +/// Print a per-language reference doc. The FIRST argument is the language (a +/// registered plugin or `base`); the optional second is the subject. Bare `docs` +/// lists the project's languages; `docs ` prints that language's catalog. pub(crate) fn run( + language: Option<&str>, subject: Option<&str>, - plugin_arg: Option<&str>, config_entries: &[String], ) -> Result<()> { - // `docs ai` is special: the playbook stands on its own and, with no plugin - // resolved, prints the intro that explains how to pick one (no hard error). - if subject.is_some_and(|s| templates::normalize_id(s) == "ai") { - return run_ai(plugin_arg, config_entries); - } - - // Every other subject is strictly per-language — a reference doc describes one - // plugin's principles + metrics — so a plugin MUST resolve. When none does, fail - // with the same diagnostic `check` / `report` give (ambiguous / no marker → name - // one with `--plugin`, or set `plugin` in `code-ranker.toml`). let input = std::path::Path::new("."); let loaded = config::load(input, config_entries, &[], &[], &[]).ok(); - let config_file = loaded.as_ref().and_then(|l| l.source_file.clone()); let cfg = loaded.map(|loaded| loaded.config); - let cfg_plugin = cfg.as_ref().and_then(|c| c.plugin.clone()); - let plugin_name = plugin::resolve_plugin( - plugin_arg, - cfg_plugin.as_deref(), - input, - config_file.as_deref(), - )?; - let specs = build_specs(&plugin_name, cfg); + // Bare `docs`: list the project's detected languages + every documentable one. + let Some(language) = language else { + print!( + "{}", + templates::with_trailing_newline(language_listing(cfg.as_ref(), input)) + ); + return Ok(()); + }; + + // Resolve an alias (`js` → `javascript`) to the canonical name so every lookup + // below works on it; `base` and unknown tokens pass through unchanged. + let language = plugin::to_canonical(language); + let language = language.as_str(); + + // The first argument is the language — a registered plugin or `base`. A value + // that is not a language is almost always a subject typed without one (e.g. + // `docs hk`, `docs ai`); point the user at the per-language form. + if !is_known_language(language) { + bail!( + "`{language}` is not a language — docs are per-language, so the language comes first:\n \ + code-ranker docs {language}\n{}", + languages_hint(cfg.as_ref(), input) + ); + } + + // `docs ai` → the offline AI-agent playbook. + if subject.is_some_and(|s| templates::normalize_id(s) == "ai") { + emit(templates::ai_doc()?); + return Ok(()); + } + + let specs = build_specs(language, cfg); let Some(subject) = subject else { - // Bare `docs`: the catalog is the help, so exit 0. + // `docs `: the full subject catalog for that language. print!( "{}", - templates::with_trailing_newline(render_catalog(&specs, None)) + templates::with_trailing_newline(render_catalog(&specs, language, None)) ); return Ok(()); }; @@ -105,8 +115,8 @@ pub(crate) fn run( } else { // Unknown subject: print the catalog so the caller sees every option, then // fail (non-zero) — it was a real lookup miss, not a help request. - emit(render_catalog(&specs, Some(subject))); - bail!("unknown docs subject {subject:?} — see the list above"); + emit(render_catalog(&specs, language, Some(subject))); + bail!("unknown docs subject {subject:?} for language {language:?} — see the list above"); } Ok(()) } @@ -115,35 +125,67 @@ fn emit(md: String) { print!("{}", templates::with_trailing_newline(md)); } -/// The `docs ai` playbook: resolve the plugin best-effort (like the rest of `docs`, -/// from `.`), then serve the full playbook or, when none resolves, the intro + a -/// filled-in *Select a language* template. -fn run_ai(plugin_arg: Option<&str>, config_entries: &[String]) -> Result<()> { - let input = Path::new("."); - let cfg_plugin = config::load(input, config_entries, &[], &[], &[]) - .ok() - .and_then(|loaded| loaded.config.plugin); - // `docs ai` carries its own *Select a language* template, so the intro only - // needs the bare "why" — pass no config hint and keep just its first line. - let md = match plugin::resolve_plugin(plugin_arg, cfg_plugin.as_deref(), input, None) { - Ok(_) => templates::ai_doc()?, - Err(reason) => { - let reason = reason.to_string(); - let why = reason.lines().next().unwrap_or(&reason); - fill_select(&templates::ai_doc_intro()?, why) - } - }; - emit(md); - Ok(()) +/// `base` (the language-agnostic catalog) or any registered plugin name. +fn is_known_language(lang: &str) -> bool { + lang == "base" || plugin::registry().iter().any(|p| p.name() == lang) +} + +/// Languages auto-detected in `input` (best-effort; empty on any failure). +fn detected_languages(cfg: Option<&config::model::Config>, input: &Path) -> Vec { + let lang_overrides = cfg.map(|c| c.plugins.languages.clone()).unwrap_or_default(); + let eff_cfgs: BTreeMap = plugin::registry() + .iter() + .map(|p| { + let name = p.name().to_string(); + ( + name.clone(), + plugin::effective_plugin_config(&name, &lang_overrides), + ) + }) + .collect(); + plugin::detect_all(&eff_cfgs, input, &PluginInput::default()) } -/// Fill the *Select a language* template (authored in `base/AI.md`) with the live -/// values: the resolver diagnostic, the built-in plugin names, the config version. -fn fill_select(intro: &str, reason: &str) -> String { - intro - .replace("{reason}", reason) - .replace("{plugins}", &plugin::names()) - .replace("{config_version}", CONFIG_VERSION) +/// One-line hint naming where to run a subject: the project's detected languages +/// (or every available one when none detected), plus `base`. +fn languages_hint(cfg: Option<&config::model::Config>, input: &Path) -> String { + let detected = detected_languages(cfg, input); + if detected.is_empty() { + format!( + "Available languages: {} (or `base` for the language-agnostic docs).", + plugin::names_with_aliases() + ) + } else { + format!( + "This project's languages: {} (or `base` for the language-agnostic docs).", + detected.join(", ") + ) + } +} + +/// The bare-`docs` listing: the project's detected languages, the other languages +/// available for docs, and how to drill in. +fn language_listing(cfg: Option<&config::model::Config>, input: &Path) -> String { + let detected = detected_languages(cfg, input); + let mut all: Vec<&str> = plugin::registry().iter().map(|p| p.name()).collect(); + all.sort_unstable(); + + let mut out = String::from("plugins (languages):\n"); + out.push_str(" - base — the language-agnostic catalog (shared defaults)\n"); + for name in &all { + if detected.iter().any(|d| d == name) { + out.push_str(&format!(" - {name} — detected in this project\n")); + } else { + out.push_str(&format!(" - {name}\n")); + } + } + out.push('\n'); + out.push_str("Run:\n"); + out.push_str(" code-ranker docs # the full subject catalog\n"); + out.push_str( + " code-ranker docs # a metric / principle / category, or `ai`\n", + ); + out } /// Build the doc specs strictly for one resolved `plugin_name`, no analysis. The @@ -153,16 +195,32 @@ fn fill_select(intro: &str, reason: &str) -> String { /// principles are the plugin catalog overlaid with `[principles.]`. Config is /// best-effort (a broken file degrades to the plugin's own specs). fn build_specs(plugin_name: &str, cfg: Option) -> DocSpecs { + // The plugin's effective config (static base ⊕ user `[languages.base]` / + // `[languages.]`), so docs reflect the same per-language overrides analysis + // would apply. With no config it degrades to the plugin's own static defaults. + let lang_overrides = cfg + .as_ref() + .map(|c| c.plugins.languages.clone()) + .unwrap_or_default(); + let eff_cfg = plugin::effective_plugin_config(plugin_name, &lang_overrides); + + // The per-language orchestrator config (`[plugins.base]` ⊕ `[plugins.]`): + // its `ignore` / `metrics` / `principles` feed the doc specs. Best-effort. + let lc = cfg + .as_ref() + .and_then(|c| c.language_config(plugin_name).ok()) + .unwrap_or_default(); + // Central, language-neutral metric specs + their category groups, refined by // the active plugin (e.g. Rust's `#[cfg(test)]` LOC nuance). let (default_metric_specs, metric_groups) = code_ranker_graph::metric_specs(); let (coupling_specs, coupling_groups) = code_ranker_graph::coupling_specs(); - let metric_specs = plugin::metric_specs(plugin_name, default_metric_specs); + let metric_specs = plugin::metric_specs(plugin_name, &eff_cfg, default_metric_specs); // The plugin's own structural attribute specs + category groups, taken from the // `files` level WITHOUT analysis — this is what surfaces language metrics like // Rust's `unsafe` that live in `[node_attributes.*]`, not the central catalog. - let files_level = plugin::levels(plugin_name) + let files_level = plugin::levels(plugin_name, &eff_cfg) .into_iter() .find(|l| l.name == "files"); let mut node_attributes = files_level @@ -176,33 +234,38 @@ fn build_specs(plugin_name: &str, cfg: Option) -> DocSpec groups.extend(metric_groups); groups.extend(coupling_groups); - let pinput = cfg - .as_ref() - .map_or_else(default_plugin_input, |c| PluginInput { - ignore: c.ignore.paths.clone(), - ignore_tests: c.ignore.tests, - gitignore: c.ignore.gitignore, - ignore_files: c.ignore.ignore_files, - hidden: c.ignore.hidden, - }); + let pinput = if cfg.is_some() { + PluginInput { + ignore: lc.ignore.paths.clone(), + ignore_tests: lc.ignore.tests, + gitignore: lc.ignore.gitignore, + ignore_files: lc.ignore.ignore_files, + hidden: lc.ignore.hidden, + } + } else { + default_plugin_input() + }; // Project node-scope declarative metrics (built-ins win a key collision). - if let Some(c) = &cfg { - for (k, d) in &c.metrics { - if d.scope == code_ranker_graph::Scope::Node { - node_attributes - .entry(k.clone()) - .or_insert_with(|| d.to_attribute_spec()); - } + for (k, d) in &lc.metrics { + if d.scope == code_ranker_graph::Scope::Node { + node_attributes + .entry(k.clone()) + .or_insert_with(|| d.to_attribute_spec()); } } - // Principles: plugin catalog overlaid with the project's `[principles.]`. - let catalog = plugin::principles(plugin_name, &pinput); - let principles = match &cfg { - Some(c) => config::merge_project_principles(catalog, &c.principles), - None => catalog, + // Principles: the plugin catalog overlaid with the language's `[principles.]`. + // `base` is the language-agnostic catalog (not a registered plugin), so its + // principles come from the neutral built-in defaults. + let catalog = if plugin_name == "base" { + code_ranker_plugins::config::resolved_principles(&code_ranker_plugins::config::load_chain( + &[], + )) + } else { + plugin::principles(plugin_name, &eff_cfg, &pinput) }; + let principles = config::merge_project_principles(catalog, &lc.principles); let templates = cfg.map(|c| c.templates).unwrap_or_default(); @@ -441,12 +504,16 @@ fn render_principle(specs: &DocSpecs, subject: &str) -> Result { /// note, for an unknown subject. A uniform two-level tree: each group (a metric /// category, then `principles`) on its own line, its members indented beneath. Every /// name on every line — group or member — is itself a valid `docs `. -fn render_catalog(specs: &DocSpecs, unknown: Option<&str>) -> String { +fn render_catalog(specs: &DocSpecs, lang: &str, unknown: Option<&str>) -> String { let mut out = String::new(); if let Some(s) = unknown { - out.push_str(&format!("Unknown docs subject `{s}`.\n\n")); + out.push_str(&format!( + "Unknown docs subject `{s}` for language `{lang}`.\n\n" + )); } - out.push_str("code-ranker docs — print a reference doc to stdout (no analysis).\n"); + out.push_str(&format!( + "code-ranker docs {lang} — print a reference doc to stdout (no analysis).\n" + )); out.push_str(&categories_block(specs)); // Principles render as one more group, exactly like a metric category. out.push_str("\n principles — SOLID & related design principles\n"); @@ -457,11 +524,11 @@ fn render_catalog(specs: &DocSpecs, unknown: Option<&str>) -> String { .map(|p| format!(" - {}: {}\n", p.id, principle_title(p))) .collect::(), ); - out.push_str( - "\nCall `docs` with any name above — e.g. `docs principles`, `docs KISS`, \ - `docs cloc`, `docs complexity`. Also `docs ai` (the agent playbook) and \ - `docs metrics` (the full metric index).\n", - ); + out.push_str(&format!( + "\nCall `docs {lang}` with any name above — e.g. `docs {lang} principles`, \ + `docs {lang} KISS`, `docs {lang} cloc`, `docs {lang} complexity`. Also \ + `docs {lang} ai` (the agent playbook) and `docs {lang} metrics` (the full metric index).\n" + )); out } diff --git a/crates/code-ranker-cli/src/docs_test.rs b/crates/code-ranker-cli/src/docs_test.rs index 3f7a8fc6..70fa4f8a 100644 --- a/crates/code-ranker-cli/src/docs_test.rs +++ b/crates/code-ranker-cli/src/docs_test.rs @@ -45,37 +45,6 @@ fn specs() -> DocSpecs { } } -#[test] -fn fill_select_injects_live_values_into_the_doc_template() { - let reason = "ambiguous project in .: markers for multiple plugins found (rust, markdown) — pass --plugin to choose"; - let md = fill_select(&templates::ai_doc_intro().unwrap(), reason); - - assert!( - md.contains("code-ranker — AI agent skill"), - "intro head present" - ); - assert!( - md.contains("## Commands") && md.contains("**`help`**") && md.contains("**`report"), - "command list present" - ); - assert!(md.contains("## Select a language"), "setup section present"); - assert!( - md.contains(reason), - "{{reason}} replaced with the diagnostic" - ); - assert!( - md.contains(&plugin::names()), - "{{plugins}} replaced with the registry names" - ); - assert!( - md.contains(&format!("version = \"{CONFIG_VERSION}\"")), - "{{config_version}} replaced with the live CONFIG_VERSION" - ); - for ph in ["{reason}", "{plugins}", "{config_version}"] { - assert!(!md.contains(ph), "placeholder {ph} fully substituted"); - } -} - #[test] fn category_subject_resolves_case_insensitively() { let s = specs(); @@ -122,9 +91,9 @@ fn render_principle_falls_back_to_a_synthetic_card_without_a_doc() { #[test] fn catalog_lists_every_subject_class() { - let out = render_catalog(&specs(), Some("zzz")); + let out = render_catalog(&specs(), "rust", Some("zzz")); assert!( - out.contains("Unknown docs subject `zzz`"), + out.contains("Unknown docs subject `zzz` for language `rust`"), "lead note: {out}" ); // Categories and their metrics (two-level): `` header. @@ -144,8 +113,8 @@ fn catalog_lists_every_subject_class() { assert!(out.contains("- TSR: Test Ratio"), "principle member: {out}"); // Closing note points at ai / metrics and the call-anything hint. assert!( - out.contains("Call `docs`") && out.contains("docs ai"), - "closing note: {out}" + out.contains("Call `docs rust`") && out.contains("docs rust ai"), + "closing note carries the language: {out}" ); } @@ -176,14 +145,14 @@ fn principles_block_reports_when_the_plugin_defines_none() { #[test] fn catalog_without_unknown_omits_the_lead_note() { // The bare-`docs` path passes `None` — the catalog is the help, so no lead note. - let out = render_catalog(&specs(), None); + let out = render_catalog(&specs(), "rust", None); assert!( !out.contains("Unknown docs subject"), "no unknown-subject note for the help view: {out}" ); assert!( - out.contains("code-ranker docs "), - "still prints the catalog header: {out}" + out.contains("code-ranker docs rust "), + "still prints the catalog header with the language: {out}" ); } @@ -252,35 +221,89 @@ fn build_specs_without_config_uses_the_plugin_catalog_and_neutral_input() { ); } +/// `base` resolves the language-agnostic principle catalog from the neutral +/// built-in defaults (not a registered plugin). +#[test] +fn build_specs_base_uses_neutral_catalog() { + let specs = build_specs("base", None); + let ids: Vec<&str> = specs.principles.iter().map(|p| p.id.as_str()).collect(); + assert!( + ids.contains(&"ADP"), + "base carries the neutral principle catalog: {ids:?}" + ); + assert!( + specs.node_attributes.contains_key("sloc"), + "central metrics present for base too" + ); +} + +/// With no language markers present, `languages_hint` lists every available +/// language rather than the project's detected set. +#[test] +fn languages_hint_lists_all_when_none_detected() { + let dir = tempfile::tempdir().unwrap(); + let hint = languages_hint(None, dir.path()); + assert!( + hint.starts_with("Available languages:"), + "no markers → the all-languages hint: {hint}" + ); + assert!(hint.contains("base"), "mentions the base catalog: {hint}"); +} + #[test] fn build_specs_overlays_project_metrics_and_principles() { let mut cfg = config::model::Config::default(); - // A node-scope `[metrics.]` becomes a first-class metric subject. - let mut def = code_ranker_graph::MetricDef { - formula_cel: "sloc * 2".to_string(), - ..Default::default() - }; - def.scope = code_ranker_graph::Scope::Node; - def.name = Some("Doubled SLOC".to_string()); - def.description = Some("Twice the source lines.".to_string()); - cfg.metrics.insert("dbl".to_string(), def); + // Populate metrics and principles via the raw [plugins.base] table so that + // `language_config("rust")` (called inside `build_specs`) picks them up. + let base = cfg.plugins.languages.entry("base".to_string()).or_default(); + + // A node-scope `[plugins.base.metrics.dbl]` becomes a first-class metric subject. + let mut dbl = toml::Table::new(); + dbl.insert( + "formula_cel".to_string(), + toml::Value::String("sloc * 2".to_string()), + ); + dbl.insert("scope".to_string(), toml::Value::String("node".to_string())); + dbl.insert( + "name".to_string(), + toml::Value::String("Doubled SLOC".to_string()), + ); + dbl.insert( + "description".to_string(), + toml::Value::String("Twice the source lines.".to_string()), + ); + // A graph-scope metric must NOT leak into the node-attribute dictionary. - let mut agg = code_ranker_graph::MetricDef { - formula_cel: "sum(sloc)".to_string(), - ..Default::default() - }; - agg.scope = code_ranker_graph::Scope::Graph; - cfg.metrics.insert("total".to_string(), agg); - // A `[principles.]` is appended to the catalog. - cfg.principles.insert( - "TSR".to_string(), - config::model::PrincipleDef { - sort_metric: "dbl".to_string(), - title: Some("TSR — Test Ratio".to_string()), - ..Default::default() - }, + let mut total = toml::Table::new(); + total.insert( + "formula_cel".to_string(), + toml::Value::String("sum(sloc)".to_string()), + ); + total.insert( + "scope".to_string(), + toml::Value::String("graph".to_string()), ); + let mut metrics = toml::Table::new(); + metrics.insert("dbl".to_string(), toml::Value::Table(dbl)); + metrics.insert("total".to_string(), toml::Value::Table(total)); + base.insert("metrics".to_string(), toml::Value::Table(metrics)); + + // A `[plugins.base.principles.TSR]` is appended to the catalog. + let mut tsr = toml::Table::new(); + tsr.insert( + "sort_metric".to_string(), + toml::Value::String("dbl".to_string()), + ); + tsr.insert( + "title".to_string(), + toml::Value::String("TSR — Test Ratio".to_string()), + ); + tsr.insert("prompt".to_string(), toml::Value::String(String::new())); + let mut principles = toml::Table::new(); + principles.insert("TSR".to_string(), toml::Value::Table(tsr)); + base.insert("principles".to_string(), toml::Value::Table(principles)); + let specs = build_specs("rust", Some(cfg)); assert!( specs.node_attributes.contains_key("dbl"), diff --git a/crates/code-ranker-cli/src/export.rs b/crates/code-ranker-cli/src/export.rs index 271594db..400ee88e 100644 --- a/crates/code-ranker-cli/src/export.rs +++ b/crates/code-ranker-cli/src/export.rs @@ -1,24 +1,23 @@ //! `--export-full-config` — dump the fully-resolved configuration as one TOML -//! document with two top-level sections: +//! document: //! - `[project]` — the merged project config: the built-in defaults //! (`config/defaults.toml`) ⊕ the discovered / `--config` project config; -//! - `[plugin]` — the active plugin's merged language config: its inheritance -//! chain `defaults.toml ⊕ [base] ⊕ .toml`. +//! - `[languages.]` — one section per EVERY registered language plugin, +//! showing the full effective config for that language (its inheritance chain +//! `defaults.toml ⊕ [base] ⊕ .toml`). A diagnostic reference dump of +//! every parameter the user may override per-language. //! -//! Diagnostic: it shows EVERY effective parameter so a user can see what they may -//! override. The two sections use different schemas (and the project / plugin -//! `principles` shapes collide), so the file is a human-facing dump, not directly -//! reusable as a single `--config`. +//! The file is human-facing (not strictly re-importable as a single `--config`). use crate::cli::AnalyzeArgs; use crate::{config, logger, plugin}; use anyhow::{Context, Result}; use std::path::Path; -/// Write the full effective configuration for `args` (`--plugin` / `--config` / +/// Write the full effective configuration for `args` (`--plugins` / `--config` / /// input) to `out`, then return — no analysis runs. pub(crate) fn export_full_config(args: &AnalyzeArgs, out: &Path) -> Result<()> { - // The workspace the config / plugin are resolved against. A best-effort + // The workspace the config / plugins are resolved against. A best-effort // canonicalize keeps auto-detection working for a relative input. let workspace = args .input @@ -30,48 +29,40 @@ pub(crate) fn export_full_config(args: &AnalyzeArgs, out: &Path) -> Result<()> { let loaded = config::load(&workspace, &args.config, &args.ignore_paths, &[], &[]) .context("configuration error")?; - // The active plugin (explicit `--plugin` > config `plugin` > marker detection) - // and its fully-merged language config table → `[plugin]`. Resolved through the - // self-registered registry + the `LanguagePlugin::config` trait method — the CLI - // never names a language. - let plugin_name = plugin::resolve_plugin( - args.plugin.as_deref(), - loaded.config.plugin.as_deref(), - &workspace, - loaded.source_file.as_deref(), - )?; - let plugin_table = plugin::registry() - .into_iter() - .find(|p| p.name() == plugin_name) - .with_context(|| { - format!( - "unknown plugin {plugin_name:?}; built-in plugins are: {}", - crate::plugin::names() - ) - })? - .config(); + // Every registered language plugin's static base config → `[languages.]`. + // This is the reference view of all overridable keys, not just the active plugins. + let reg = plugin::registry(); + let mut languages = toml::Table::new(); + for p in ® { + let lang_table = p.config(); + languages.insert(p.name().to_string(), toml::Value::Table(lang_table)); + } + + let project_src = loaded + .source_file + .as_deref() + .unwrap_or("built-in defaults (no project config file found)"); let mut doc = toml::Table::new(); doc.insert("project".into(), toml::Value::Table(loaded.merged)); - doc.insert("plugin".into(), toml::Value::Table(plugin_table)); + doc.insert("languages".into(), toml::Value::Table(languages)); let body = toml::to_string_pretty(&doc).context("serializing full config")?; let header = format!( "# code-ranker — full effective configuration (diagnostic dump).\n\ - # [project] = built-in defaults ⊕ {project_src}\n\ - # [plugin] = merged config for plugin `{plugin_name}`\n\ - # Two schemas in one file: a human-facing view of every effective\n\ - # parameter, not directly reusable as a single --config.\n\n", - project_src = loaded - .source_file - .as_deref() - .unwrap_or("built-in defaults (no project config file found)"), + # [project] = built-in defaults ⊕ {project_src}\n\ + # [languages.] = static base config for each registered language\n\ + # (every key you can override in [languages.])\n\ + # Human-facing reference dump; not directly reusable as a single --config.\n\n" ); std::fs::write(out, format!("{header}{body}")) .with_context(|| format!("writing {}", out.display()))?; + let lang_names: Vec<&str> = reg.iter().map(|p| p.name()).collect(); logger::summary(&format!( - "✓ wrote full config for plugin `{plugin_name}` to {}", + "✓ wrote full config ({} languages: {}) to {}", + lang_names.len(), + lang_names.join(", "), out.display() )); Ok(()) @@ -83,10 +74,10 @@ mod tests { use crate::cli::AnalyzeArgs; use std::path::PathBuf; - fn args(input: PathBuf, plugin: &str, config: Vec) -> AnalyzeArgs { + fn args(input: PathBuf, config: Vec) -> AnalyzeArgs { AnalyzeArgs { input, - plugin: Some(plugin.to_string()), + plugins: Vec::new(), config, ignore_paths: Vec::new(), git_branch: None, @@ -97,14 +88,14 @@ mod tests { } #[test] - fn export_writes_project_and_plugin_sections_with_merge() { + fn export_writes_project_and_languages_sections() { let dir = tempfile::tempdir().unwrap(); // A partial project config: one override; everything else inherits defaults. let cfg = dir.path().join("code-ranker.toml"); std::fs::write( &cfg, format!( - "version = \"{}\"\n[ignore]\ntests = false\n", + "version = \"{}\"\n[plugins.base.ignore]\ntests = false\n", code_ranker_graph::version::CONFIG_VERSION ), ) @@ -112,35 +103,27 @@ mod tests { let out = dir.path().join("full.toml"); export_full_config( - &args( - dir.path().to_path_buf(), - "python", - vec![cfg.display().to_string()], - ), + &args(dir.path().to_path_buf(), vec![cfg.display().to_string()]), &out, ) .unwrap(); let doc: toml::Table = std::fs::read_to_string(&out).unwrap().parse().unwrap(); let project = doc["project"].as_table().unwrap(); - let plugin = doc["plugin"].as_table().unwrap(); + let languages = doc["languages"].as_table().unwrap(); - // [project]: the override wins, the rest is inherited from the built-in defaults. - assert_eq!(project["ignore"]["tests"].as_bool(), Some(false)); - assert_eq!(project["ignore"]["gitignore"].as_bool(), Some(true)); + // [project]: the override wins, the rest is inherited from the built-in + // defaults. The per-language sections live under `plugins.base`. + let base = &project["plugins"]["base"]; + assert_eq!(base["ignore"]["tests"].as_bool(), Some(false)); + assert_eq!(base["ignore"]["gitignore"].as_bool(), Some(true)); assert!(project["output"]["json"]["path"].as_str().is_some()); - // [plugin]: the resolved python language config (its principles catalog). - assert_eq!(plugin["doc_lang"].as_str(), Some("python")); - assert!(!plugin["principles"].as_array().unwrap().is_empty()); - } - - #[test] - fn export_errors_on_unknown_plugin() { - let dir = tempfile::tempdir().unwrap(); - let out = dir.path().join("full.toml"); - let err = export_full_config(&args(dir.path().to_path_buf(), "klingon", Vec::new()), &out) - .unwrap_err() - .to_string(); - assert!(err.contains("klingon"), "names the bad plugin: {err}"); + // [languages]: every registered language is present. + assert!(!languages.is_empty(), "at least one language registered"); + // Python language config has its doc_lang field. + if let Some(py) = languages.get("python") { + assert_eq!(py["doc_lang"].as_str(), Some("python")); + assert!(!py["principles"].as_array().unwrap().is_empty()); + } } } diff --git a/crates/code-ranker-cli/src/main.rs b/crates/code-ranker-cli/src/main.rs index d1b4207c..1db5bea2 100644 --- a/crates/code-ranker-cli/src/main.rs +++ b/crates/code-ranker-cli/src/main.rs @@ -88,11 +88,10 @@ fn main() { output_sarif_path, output_codequality, output_codequality_path, - output_prompt, output_scorecard, - output_prompt_path, output_scorecard_path, focus, + language, focus_path, severity, top, @@ -110,17 +109,16 @@ fn main() { html: output_html, sarif: output_sarif, codequality: output_codequality, - prompt: output_prompt, scorecard: output_scorecard, json_path: output_json_path, html_path: output_html_path, sarif_path: output_sarif_path, codequality_path: output_codequality_path, - prompt_path: output_prompt_path, scorecard_path: output_scorecard_path, }, report::ReportReco { focus, + language, focus_path, severity, top, @@ -134,10 +132,10 @@ fn main() { // playbook, an index, a category, a metric card, or a principle doc. See // `docs.rs`. Command::Docs { + language, subject, - plugin, config, - } => docs::run(subject.as_deref(), plugin.as_deref(), &config), + } => docs::run(language.as_deref(), subject.as_deref(), &config), }; match res { Ok(()) => { diff --git a/crates/code-ranker-cli/src/pipeline.rs b/crates/code-ranker-cli/src/pipeline.rs index f7574eb6..b5ff6e83 100644 --- a/crates/code-ranker-cli/src/pipeline.rs +++ b/crates/code-ranker-cli/src/pipeline.rs @@ -1,7 +1,8 @@ -//! Directory-analysis pipeline: run the plugin, the central complexity / -//! coupling / cycle passes, assemble the `LevelGraph`, and build the `Snapshot`. -//! Owns [`Analyzed`] (the shared result). Called only from `analyze::analyze_input` -//! (fan-in 1), so its necessarily-high fan-out stays cheap under Henry-Kafura. +//! Directory-analysis pipeline: run each active plugin, the central complexity / +//! coupling / cycle passes, assemble the per-language `LevelGraph`s, and build +//! the multi-language `Snapshot`. Owns [`Analyzed`] (the shared result). Called +//! only from `analyze::analyze_input` (fan-in 1), so its necessarily-high +//! fan-out stays cheap under Henry-Kafura. mod assemble; mod helpers; @@ -10,9 +11,11 @@ use crate::cli::AnalyzeArgs; use crate::{config, git, logger, plugin}; use anyhow::{Context, Result}; use assemble::assemble_level; -use code_ranker_graph::snapshot::Snapshot; +use code_ranker_graph::snapshot::{LanguageSnapshot, Snapshot, SnapshotInit}; use code_ranker_plugin_api::plugin::PluginInput; -use helpers::{flow_kinds, numeric_attrs, prune_unused_roots}; +use helpers::{ + flow_kinds, numeric_attrs, prune_unused_roots, prune_unused_roots_multi, registry_omit_at, +}; use std::collections::{BTreeMap, HashSet}; /// Result of the shared analysis core, consumed by `check` and `report`. The @@ -20,104 +23,80 @@ use std::collections::{BTreeMap, HashSet}; pub(crate) struct Analyzed { pub(crate) snapshot: Snapshot, pub(crate) violations: Vec, - /// Effective cycle-rule policy (for the current-values config dump). - pub(crate) cycles: config::CycleRules, - /// Effective rules (to recompute baseline violations for the regression gate). - pub(crate) rules: config::RulesConfig, + /// Effective per-language rules (to recompute baseline violations for the + /// regression gate and to dump current values), keyed by language. + pub(crate) rules_by_lang: BTreeMap, /// `[output.]` config: per-format `path` template and `enabled` flag /// (CLI flags still win — resolved in `run_report`). pub(crate) output: config::OutputConfig, } -/// Directory input: load config, run the plugin, annotate the graphs, collect -/// violations, and assemble the snapshot. Writes nothing. -pub(crate) fn analyze_directory( - args: &AnalyzeArgs, - cycle_rules: &[String], - thresholds: &[String], -) -> Result { - let target = args - .input - .canonicalize() - .with_context(|| format!("input not found: {}", args.input.display()))?; - let cwd = std::env::current_dir()?; - - // A bad config (malformed file, unknown scope/metric, bad inline override) is a - // hard error — silently falling back to defaults would drop the user's rules and - // let `check` pass when it should fail (a false green for a CI gate). - let loaded = config::load( - &target, - &args.config, - &args.ignore_paths, - cycle_rules, - thresholds, - ) - .context("configuration error")?; - let cfg = loaded.config; - - let plugin_name = plugin::resolve_plugin( - args.plugin.as_deref(), - cfg.plugin.as_deref(), - &target, - loaded.source_file.as_deref(), - )?; - - let command = format!( - "code-ranker {}", - std::env::args().skip(1).collect::>().join(" ") - ); +/// The per-language analysis result before it is merged into the snapshot. +struct AnalyzedLanguage { + graphs: BTreeMap, + principles: Vec, + prompt: code_ranker_plugin_api::PromptTemplate, + roots: BTreeMap, + versions: Vec<(String, String)>, + timings: Vec, + /// `true` when the graph produced at least one non-external node; languages + /// with `false` here are dropped from the active set. + had_nodes: bool, +} - let input = PluginInput { - ignore: cfg.ignore.paths.clone(), - ignore_tests: cfg.ignore.tests, - gitignore: cfg.ignore.gitignore, - ignore_files: cfg.ignore.ignore_files, - hidden: cfg.ignore.hidden, - }; +/// Parse, enrich, and assemble one language's analysis output. +/// +/// This is the per-language unit of work extracted from the old single-plugin +/// `analyze_directory`. `eff_cfg` is the fully-built effective plugin config +/// (static base ⊕ `[languages.base]` ⊕ `[languages.]` ⊕ CLI overrides). +#[allow(clippy::too_many_arguments)] +fn analyze_one( + plugin_name: &str, + target: &std::path::Path, + input: &PluginInput, + eff_cfg: &toml::Table, + lang_cfg: &config::model::LangConfig, + prompt_override: Option<&str>, +) -> Result { + let mut timings = Vec::new(); // 1. Parse structure (absolute file-path ids). - let mut timings = Vec::new(); - let t = logger::Timer::start("parse: structure"); - let (mut graph, levels) = plugin::analyze(&plugin_name, &target, &input) + let t = logger::Timer::start(&format!("{plugin_name}: parse")); + let (mut graph, levels) = plugin::analyze(plugin_name, eff_cfg, target, input) .with_context(|| format!("plugin '{plugin_name}' failed"))?; let file_count = graph.nodes.iter().filter(|n| n.kind == "file").count(); timings.push(code_ranker_graph::snapshot::StageTime { - stage: plugin_name.clone(), + stage: format!("{plugin_name}: parse"), ms: t.finish_quiet(), detail: format!("{} nodes from {} files", graph.nodes.len(), file_count), }); - // 2. Complexity pass: the active plugin annotates its own file nodes with - // per-language metrics (behind the `LanguagePlugin` trait — no central - // by-extension dispatcher). Reads files by their absolute id. - let t = logger::Timer::start("complexity"); - let annotated = plugin::annotate_metrics(&plugin_name, &mut graph); + let had_nodes = graph.nodes.iter().any(|n| n.kind != "external"); + + // 2. Complexity: plugin annotates its own file nodes with per-language metrics. + let t = logger::Timer::start(&format!("{plugin_name}: complexity")); + let annotated = plugin::annotate_metrics(plugin_name, eff_cfg, &mut graph); timings.push(code_ranker_graph::snapshot::StageTime { - stage: "complexity".into(), + stage: format!("{plugin_name}: complexity"), ms: t.finish_quiet(), detail: format!("{annotated} nodes annotated"), }); // 3. Canonicalize structure, then relativize ids against detected roots. - // The active plugin contributes its own language/toolchain roots (e.g. the - // Rust plugin's cargo/registry/rustup/rust-src); the orchestrator only owns - // the generic `target` root — no language leaks into this central step. - let t = logger::Timer::start("projection"); + let t = logger::Timer::start(&format!("{plugin_name}: projection")); code_ranker_graph::finalize::finalize_graph(&mut graph); - let mut roots: BTreeMap = - plugin::roots(&plugin_name, &target).into_iter().collect(); + let mut roots: BTreeMap = plugin::roots(plugin_name, eff_cfg, target) + .into_iter() + .collect(); roots.insert("target".to_string(), target.display().to_string()); - // Optional `functions` level (off by default): the plugin builds sub-file - // metric nodes (absolute ids) which we merge in so relativization rewrites - // their ids/parents alongside the files, then split back out — the `files` - // graph and its goldens stay untouched. - let want_functions = cfg.levels.functions; + // Optional `functions` level: plugin builds sub-file metric nodes. + let want_functions = lang_cfg.levels.functions; if want_functions { - let fns = plugin::function_units(&plugin_name, &graph); + let fns = plugin::function_units(plugin_name, eff_cfg, &graph); graph.nodes.extend(fns); } - code_ranker_graph::relativize::relativize_graph(&mut graph, &target, &roots); + code_ranker_graph::relativize::relativize_graph(&mut graph, target, &roots); let mut fn_nodes: Vec = Vec::new(); if want_functions { graph.nodes.retain(|n| { @@ -130,10 +109,10 @@ pub(crate) fn analyze_directory( }); } - // 4. Apply ignore filters (tokenized ids), then compute the derived data. - config::apply_ignore(&mut graph, &cfg.ignore, &target)?; + // 4. Apply ignore filters (tokenized ids), then compute derived data. + config::apply_ignore(&mut graph, &lang_cfg.ignore, target)?; - // Drop function nodes whose file was ignored above (keep the two in step). + // Drop function nodes whose file was ignored above. if want_functions { let file_ids: HashSet<&str> = graph.nodes.iter().map(|n| n.id.as_str()).collect(); fn_nodes.retain(|n| n.parent.as_deref().is_some_and(|p| file_ids.contains(p))); @@ -146,45 +125,33 @@ pub(crate) fn analyze_directory( .map(|i| levels.remove(i)); let level_spec = levels.into_iter().find(|l| l.name == "files"); let flow_kinds = flow_kinds(level_spec.as_ref()); - // Cycles, fan-in/fan-out and the drawn map all run on the same flow edges. A - // `pub use` re-export is a facade, not a dependency, so the Rust plugin marks - // `reexports` non-flow (`EdgeKindSpec.flow = false`) — it never reaches any of - // these and re-export hubs (lib.rs / mod.rs) cannot fabricate cycles. + let mut cycles = code_ranker_graph::cycles::annotate_cycles(&mut graph, &flow_kinds); - config::apply_cycle_rules(&mut cycles, &mut graph.nodes, &cfg.rules.cycles); + config::apply_cycle_rules(&mut cycles, &mut graph.nodes, &lang_cfg.rules.cycles); code_ranker_graph::annotate_coupling(&mut graph, &flow_kinds); - // Graph-derived built-in metrics (e.g. `hk`): now that the coupling pass has - // written `fan_in`/`fan_out` onto the nodes, evaluate the `[fields.*]` formulas - // that read them — the TIER1 → graph → TIER2 order. Pre-graph fields - // (volume/mi/…) were already written from the raw tier-1 counts above. + // Graph-derived built-in metrics (e.g. `hk`). for node in &mut graph.nodes { if node.kind != "external" { code_ranker_graph::write_derived(node); } } - // User-defined declarative metrics: evaluate each `[metrics.]` CEL - // formula. Node-scope metrics are written onto every internal node (built-in - // attributes — including the just-computed coupling — are inputs); graph-scope - // (aggregate) metrics are reduced over the whole node set into `stats` below. - // Empty registry → no-op, so the default output (and its goldens) is unchanged. + // User-defined declarative metrics. let mut custom_specs: BTreeMap = BTreeMap::new(); - let engine = if cfg.metrics.is_empty() { + let engine = if lang_cfg.metrics.is_empty() { None } else { - let engine = code_ranker_graph::registry::Engine::compile(&cfg.metrics) + let engine = code_ranker_graph::registry::Engine::compile(&lang_cfg.metrics) .context("compiling [metrics] formulas")?; for node in &mut graph.nodes { if node.kind == "external" { continue; } - code_ranker_graph::apply_to_node(node, &cfg.metrics, &engine); + code_ranker_graph::apply_to_node(node, &lang_cfg.metrics, &engine); } - // Only node-scope metrics become node-attribute columns; graph-scope keys - // never sit on a node, so they would be pruned anyway. - custom_specs = cfg + custom_specs = lang_cfg .metrics .iter() .filter(|(_, d)| d.scope == code_ranker_graph::Scope::Node) @@ -193,19 +160,11 @@ pub(crate) fn analyze_directory( Some(engine) }; - // The active plugin's report-list patches (table columns / card / JSON - // stats), applied over the global catalog lists below. - // The report-list patches applied over the catalog lists, in order: the - // language's `[report]` (from `.toml`), then the project's `[report]` - // (from `code-ranker.toml`) — so a project can surface its own metrics. let report_overrides = [ - plugin::report_overrides(&plugin_name), - code_ranker_plugin_api::list_override::report_override_section(&cfg.report), + plugin::report_overrides(plugin_name, eff_cfg), + code_ranker_plugin_api::list_override::report_override_section(&lang_cfg.report), ]; - // Stat keys are data-driven: tier-2 metrics from the registry plus the - // coupling metrics (computed by the graph passes above), then patched by the - // language's `[report].stats` (e.g. Rust adds `unsafe`). let mut stat_keys = code_ranker_graph::stat_keys(); stat_keys.extend([ "fan_in".to_string(), @@ -217,12 +176,11 @@ pub(crate) fn analyze_directory( .fold(stat_keys, |acc, ov| ov.stats.apply(&acc)); let mut stats = code_ranker_graph::stats::compute_stats(&graph, &stat_keys); - // Graph-scope aggregates → merged into the stats block (e.g. a user's - // `cyclomatic_p90 = agg('cyclomatic','p90','not_empty')`). + // Graph-scope aggregates. if let Some(engine) = &engine && engine.has_graph_metrics() { - let omit_at = registry_omit_at(&plugin_name, &cfg.metrics); + let omit_at = registry_omit_at(plugin_name, eff_cfg, &lang_cfg.metrics); let rows: Vec> = graph .nodes .iter() @@ -240,11 +198,8 @@ pub(crate) fn analyze_directory( } } - // Warn on any declared metric that produced no value across the whole - // project. Catches the otherwise-silent failure mode — a formula that errors - // on every node (e.g. a misspelled input key resolves to nothing) — so a - // typo'd metric doesn't just vanish without a trace. - for (key, def) in &cfg.metrics { + // Warn on declared metrics that produced no value. + for (key, def) in &lang_cfg.metrics { let present = match def.scope { code_ranker_graph::Scope::Graph => stats.contains_key(key), code_ranker_graph::Scope::Node => graph @@ -262,7 +217,7 @@ pub(crate) fn analyze_directory( let edge_count = graph.edges.len(); let node_count = graph.nodes.len(); - let thresholds = gate_thresholds(&cfg); + let thresholds = gate_thresholds(lang_cfg); let level = assemble_level( level_spec, graph, @@ -270,12 +225,13 @@ pub(crate) fn analyze_directory( stats, thresholds.clone(), &custom_specs, - &plugin_name, + plugin_name, + eff_cfg, &report_overrides, ); prune_unused_roots(&level, &mut roots); timings.push(code_ranker_graph::snapshot::StageTime { - stage: "projection".into(), + stage: format!("{plugin_name}: projection"), ms: t.finish_quiet(), detail: format!("nodes={node_count} edges={edge_count}"), }); @@ -283,9 +239,6 @@ pub(crate) fn analyze_directory( let mut graphs = BTreeMap::new(); graphs.insert("files".to_string(), level); - // Assemble the optional `functions` level from the split-out sub-file nodes. - // Reuses the same assembler: metric specs are merged and pruned to the keys - // present on function nodes (coupling specs drop out — functions carry none). if want_functions && !fn_nodes.is_empty() { let fn_graph = code_ranker_plugin_api::graph::Graph { nodes: fn_nodes, @@ -298,43 +251,21 @@ pub(crate) fn analyze_directory( BTreeMap::new(), thresholds, &custom_specs, - &plugin_name, + plugin_name, + eff_cfg, &report_overrides, ); graphs.insert("functions".to_string(), fn_level); } - let violations = config::check_violations(&graphs, &cfg.rules); - - let git = git::collect( - &target, - &git::GitOverride { - branch: args.git_branch.clone(), - commit: args.git_commit.clone(), - dirty_files: args.git_dirty_files, - origin: args.git_origin.clone(), - }, - ); - - let mut versions = BTreeMap::new(); - versions.insert( - "code-ranker".to_string(), - env!("CARGO_PKG_VERSION").to_string(), + // Plugin catalog principles, then the project's `[principles.]` overrides. + let principles = config::merge_project_principles( + plugin::principles(plugin_name, eff_cfg, input), + &lang_cfg.principles, ); - for (k, v) in plugin::versions(&plugin_name, &target, &input) { - versions.insert(k, v); - } - // Plugin catalog principles, then the project's own (`[principles.]`): a - // same-id project principle overrides the plugin's, a new id appends. So a - // project can recommend / scorecard on its custom metric. - let principles = - config::merge_project_principles(plugin::principles(&plugin_name, &input), &cfg.principles); - - // Prompt-Generator scaffolding: the built-in `metrics/prompt.md`, or a - // `[templates] prompt = ""` override read from disk (same `## ` - // Markdown shape). - let prompt = match &cfg.templates.prompt { + // Prompt-Generator scaffolding. + let prompt = match prompt_override { Some(path) => code_ranker_graph::prompt_template_from( &std::fs::read_to_string(path) .with_context(|| format!("reading [templates] prompt override {path}"))?, @@ -342,26 +273,191 @@ pub(crate) fn analyze_directory( None => code_ranker_graph::prompt_template(), }; - let snapshot = Snapshot::new( - command, - cwd.display().to_string(), - target.display().to_string(), - plugin_name, - loaded.source_file, - versions, - roots, - git, - timings, + let versions = plugin::versions(plugin_name, eff_cfg, target, input); + + Ok(AnalyzedLanguage { graphs, principles, prompt, + roots, + versions, + timings, + had_nodes, + }) +} + +/// Directory input: load config, resolve active plugins, run `analyze_one` for +/// each, drop empty languages, assemble the multi-language snapshot. +pub(crate) fn analyze_directory( + args: &AnalyzeArgs, + cycle_rules: &[String], + thresholds: &[String], +) -> Result { + let target = args + .input + .canonicalize() + .with_context(|| format!("input not found: {}", args.input.display()))?; + let cwd = std::env::current_dir()?; + + // A bad config (malformed file, unknown scope/metric, bad inline override) is a + // hard error — silently falling back to defaults would drop the user's rules and + // let `check` pass when it should fail (a false green for a CI gate). + let loaded = config::load( + &target, + &args.config, + &args.ignore_paths, + cycle_rules, + thresholds, + ) + .context("configuration error")?; + let cfg = loaded.config; + + let command = format!( + "code-ranker {}", + std::env::args().skip(1).collect::>().join(" ") ); + // A PluginInput (ignore filters) from a language's effective `[ignore]`. + let plugin_input = |lc: &config::model::LangConfig| PluginInput { + ignore: lc.ignore.paths.clone(), + ignore_tests: lc.ignore.tests, + gitignore: lc.ignore.gitignore, + ignore_files: lc.ignore.ignore_files, + hidden: lc.ignore.hidden, + }; + // Detection uses the base-language ignore (the active set is not yet known). + let input = plugin_input(&cfg.language_config("base")?); + + // Build effective configs for EVERY registered plugin (needed for detect_all + // to use the user's overrides when matching markers and extensions). + let all_names: Vec = plugin::registry() + .iter() + .map(|p| p.name().to_string()) + .collect(); + let all_eff_cfgs: BTreeMap = all_names + .iter() + .map(|name| { + let eff = plugin::effective_plugin_config(name, &cfg.plugins.languages); + (name.clone(), eff) + }) + .collect(); + + // Resolve the active plugin list (console > config > auto-detect). + let active_plugins = plugin::resolve_plugins( + &args.plugins, + &cfg.plugins.enabled, + &all_eff_cfgs, + &target, + &input, + loaded.source_file.as_deref(), + )?; + + // Guard: no two active plugins may claim the same file extension. + let active_eff_cfgs: BTreeMap = active_plugins + .iter() + .filter_map(|name| all_eff_cfgs.get(name).map(|t| (name.clone(), t.clone()))) + .collect(); + plugin::validate_extension_uniqueness(&active_plugins, &active_eff_cfgs)?; + + // Run each active plugin through analyze_one. + let mut languages: BTreeMap = BTreeMap::new(); + let mut combined_roots: BTreeMap = BTreeMap::new(); + combined_roots.insert("target".to_string(), target.display().to_string()); + let mut combined_versions: BTreeMap = BTreeMap::new(); + combined_versions.insert( + "code-ranker".to_string(), + env!("CARGO_PKG_VERSION").to_string(), + ); + let mut combined_timings: Vec = Vec::new(); + let mut active_final: Vec = Vec::new(); + + // Per-language effective orchestrator config (rules/ignore/metrics/levels/ + // report/principles), resolved once per active language and reused for the gate. + let mut rules_by_lang: BTreeMap = BTreeMap::new(); + + for name in &active_plugins { + let eff_cfg = active_eff_cfgs.get(name).cloned().unwrap_or_default(); + let lang_cfg = cfg.language_config(name)?; + let lang_input = plugin_input(&lang_cfg); + let result = analyze_one( + name, + &target, + &lang_input, + &eff_cfg, + &lang_cfg, + cfg.templates.prompt.as_deref(), + )?; + + if !result.had_nodes { + logger::summary(&format!( + "⚠ plugin '{name}' produced no nodes — skipping (no source files found?)" + )); + continue; + } + + // Merge roots and versions (last-writer-wins for collisions). + combined_roots.extend(result.roots); + for (k, v) in result.versions { + combined_versions.insert(k, v); + } + combined_timings.extend(result.timings); + active_final.push(name.clone()); + rules_by_lang.insert(name.clone(), lang_cfg.rules); + + languages.insert( + name.clone(), + LanguageSnapshot { + graphs: result.graphs, + principles: result.principles, + prompt: result.prompt, + }, + ); + } + + if languages.is_empty() { + anyhow::bail!( + "all detected languages produced empty graphs in {} — \ + no source files were analysed", + target.display() + ); + } + + // Runtime guarantee of the one-file-one-language invariant (see helper). + helpers::assert_disjoint_languages(&languages)?; + + // Prune roots that are not referenced by any node across all languages. + prune_unused_roots_multi(&languages, &mut combined_roots); + + let violations = config::check_violations_all(&languages, &rules_by_lang); + + let git = git::collect( + &target, + &git::GitOverride { + branch: args.git_branch.clone(), + commit: args.git_commit.clone(), + dirty_files: args.git_dirty_files, + origin: args.git_origin.clone(), + }, + ); + + active_final.sort(); + let snapshot = Snapshot::new(SnapshotInit { + command, + workspace: cwd.display().to_string(), + target: target.display().to_string(), + plugins: active_final, + config_file: loaded.source_file, + versions: combined_versions, + roots: combined_roots, + git, + timings: combined_timings, + languages, + }); + Ok(Analyzed { snapshot, violations, - cycles: cfg.rules.cycles, - rules: cfg.rules, + rules_by_lang, output: cfg.output, }) } @@ -373,15 +469,16 @@ pub(crate) fn analyze_directory( /// `[metrics.]` spec) is kept only when it sits strictly below the gate, /// otherwise it is meaningless and collapses to the gate (one effective tier). fn gate_thresholds( - cfg: &config::model::Config, + lang_cfg: &config::model::LangConfig, ) -> BTreeMap { - cfg.rules + lang_cfg + .rules .thresholds .file .limits .iter() .map(|(key, &warning)| { - let declared_info = cfg.metrics.get(key).and_then(|d| d.info); + let declared_info = lang_cfg.metrics.get(key).and_then(|d| d.info); let info = match declared_info { Some(i) if i < warning => i, Some(i) => { @@ -401,29 +498,6 @@ fn gate_thresholds( .collect() } -/// The `omit_at` (no-signal floor) of every metric key, so an aggregate's `all` -/// population counts a missing value at the right floor (`0` for most, `1` for -/// `cyclomatic`). Built from the central + plugin-refined + coupling specs, then -/// the user's own metric defs. -fn registry_omit_at( - plugin_name: &str, - custom: &BTreeMap, -) -> BTreeMap { - let mut m = BTreeMap::new(); - let (specs, _) = code_ranker_graph::metric_specs(); - for (k, s) in plugin::metric_specs(plugin_name, specs) { - m.insert(k, s.omit_at); - } - let (coupling, _) = code_ranker_graph::coupling_specs(); - for (k, s) in coupling { - m.insert(k, s.omit_at); - } - for (k, d) in custom { - m.insert(k.clone(), d.omit_at); - } - m -} - #[cfg(test)] #[path = "pipeline_test.rs"] mod tests; diff --git a/crates/code-ranker-cli/src/pipeline/assemble.rs b/crates/code-ranker-cli/src/pipeline/assemble.rs index 96624a8c..36d4a641 100644 --- a/crates/code-ranker-cli/src/pipeline/assemble.rs +++ b/crates/code-ranker-cli/src/pipeline/assemble.rs @@ -21,6 +21,7 @@ pub(super) fn assemble_level( thresholds: BTreeMap, custom_specs: &BTreeMap, plugin_name: &str, + eff_cfg: &toml::Table, report_overrides: &[code_ranker_plugin_api::report::ReportOverride], ) -> LevelGraph { use std::collections::BTreeSet; @@ -39,9 +40,10 @@ pub(super) fn assemble_level( // Master node-attribute dictionary = structural (plugin) + computed. let mut node_attributes = spec.node_attributes; // Language-neutral default metric specs, refined by the active plugin (e.g. - // Rust adds the `#[cfg(test)]` nuance to the LOC descriptions). + // Rust adds the `#[cfg(test)]` nuance to the LOC descriptions). Passes the + // effective config so any user overrides reach the plugin's refinement. let (default_metric_specs, metric_groups) = code_ranker_graph::metric_specs(); - let metric_specs = plugin::metric_specs(plugin_name, default_metric_specs); + let metric_specs = plugin::metric_specs(plugin_name, eff_cfg, default_metric_specs); let (coupling_specs, coupling_groups) = code_ranker_graph::coupling_specs(); node_attributes.extend(metric_specs); node_attributes.extend(coupling_specs); diff --git a/crates/code-ranker-cli/src/pipeline/helpers.rs b/crates/code-ranker-cli/src/pipeline/helpers.rs index 38342e7d..2599dc23 100644 --- a/crates/code-ranker-cli/src/pipeline/helpers.rs +++ b/crates/code-ranker-cli/src/pipeline/helpers.rs @@ -54,3 +54,140 @@ pub(super) fn prune_unused_roots(level: &LevelGraph, roots: &mut BTreeMap, + roots: &mut BTreeMap, +) { + let mut used: HashSet = HashSet::new(); + used.insert("target".to_string()); + for ls in languages.values() { + for level in ls.graphs.values() { + for node in &level.nodes { + let path_attr = match node.attrs.get("path") { + Some(code_ranker_plugin_api::attrs::AttrValue::Str(p)) => p.as_str(), + _ => "", + }; + for name in roots.keys() { + let token = format!("{{{name}}}"); + if node.id.contains(&token) || path_attr.contains(&token) { + used.insert(name.clone()); + } + } + } + } + } + roots.retain(|name, _| used.contains(name)); +} + +/// The `omit_at` (no-signal floor) of every metric key, so an aggregate's `all` +/// population counts a missing value at the right floor (`0` for most, `1` for +/// `cyclomatic`). Built from the central + plugin-refined + coupling specs, then +/// the user's own metric defs. +pub(super) fn registry_omit_at( + plugin_name: &str, + eff_cfg: &toml::Table, + custom: &BTreeMap, +) -> BTreeMap { + let mut m = BTreeMap::new(); + let (specs, _) = code_ranker_graph::metric_specs(); + for (k, s) in crate::plugin::metric_specs(plugin_name, eff_cfg, specs) { + m.insert(k, s.omit_at); + } + let (coupling, _) = code_ranker_graph::coupling_specs(); + for (k, s) in coupling { + m.insert(k, s.omit_at); + } + for (k, d) in custom { + m.insert(k.clone(), d.omit_at); + } + m +} + +/// Enforce the one-file-one-language invariant: the active languages' internal +/// (non-external) node sets must be disjoint. The extension-uniqueness check +/// covers extension-based plugins; this also catches any residual overlap (e.g. +/// Rust's cargo-metadata paths, which carry no `extensions`). A duplicate means a +/// file was analysed by two languages — double-counting it and breaking the +/// `--focus`/`--focus-path` path→language mapping. +pub(super) fn assert_disjoint_languages( + languages: &std::collections::BTreeMap, +) -> anyhow::Result<()> { + let mut seen: HashSet<&str> = HashSet::new(); + for ls in languages.values() { + for level in ls.graphs.values() { + for node in &level.nodes { + if node.kind == "external" { + continue; + } + if !seen.insert(node.id.as_str()) { + debug_assert!(false, "file {} claimed by >1 language", node.id); + // COVERAGE: release-only — under test/debug the `debug_assert!` + // above panics first, so this `bail!` (the production fallback) + // is unreachable when coverage is instrumented. + anyhow::bail!( + "internal error: file {:?} was analysed by more than one language; \ + adjust `extensions` / `plugins` so each file maps to exactly one language", + node.id + ); + } + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use code_ranker_graph::snapshot::LanguageSnapshot; + use code_ranker_plugin_api::node::Node; + + /// A single-language snapshot whose `files` level holds one node. + fn lang_with_node(id: &str, kind: &str) -> LanguageSnapshot { + let level = LevelGraph { + nodes: vec![Node { + id: id.into(), + kind: kind.into(), + name: id.into(), + parent: None, + attrs: Default::default(), + }], + ..Default::default() + }; + let mut graphs = BTreeMap::new(); + graphs.insert("files".to_string(), level); + LanguageSnapshot { + graphs, + principles: vec![], + prompt: Default::default(), + } + } + + /// Distinct internal files pass; a shared id that is `external` in one language + /// is exempt (external nodes are third-party, not owned by a language). + #[test] + fn assert_disjoint_languages_accepts_distinct_and_external() { + let mut langs = BTreeMap::new(); + langs.insert("rust".to_string(), lang_with_node("a.rs", "file")); + // same id but external → ignored by the check + langs.insert("python".to_string(), lang_with_node("a.rs", "external")); + langs.insert("go".to_string(), lang_with_node("b.go", "file")); + assert!(assert_disjoint_languages(&langs).is_ok()); + } + + /// Two languages claiming the same internal file trip the invariant. In a + /// debug/test build the `debug_assert!` fires (the dev guard); the `bail!` + /// fallback only runs in release. + #[test] + #[should_panic(expected = "claimed by >1 language")] + fn assert_disjoint_languages_rejects_shared_internal_file() { + let mut langs = BTreeMap::new(); + langs.insert("rust".to_string(), lang_with_node("dup.rs", "file")); + langs.insert("ts".to_string(), lang_with_node("dup.rs", "file")); + let _ = assert_disjoint_languages(&langs); + } +} diff --git a/crates/code-ranker-cli/src/pipeline_test.rs b/crates/code-ranker-cli/src/pipeline_test.rs index 57d8d152..77c97660 100644 --- a/crates/code-ranker-cli/src/pipeline_test.rs +++ b/crates/code-ranker-cli/src/pipeline_test.rs @@ -50,15 +50,15 @@ fn gate_thresholds_uses_gate_as_warning_and_reconciles_info() { format!( "version = \"{}\"\n{}", code_ranker_graph::version::CONFIG_VERSION, - r#"[metrics.below] + r#"[plugins.base.metrics.below] formula_cel = "sloc" info = 50 -[metrics.above] +[plugins.base.metrics.above] formula_cel = "sloc" info = 200 -[rules.thresholds.file] +[plugins.base.rules.thresholds.file] hk = 500000 below = 100 above = 100 @@ -68,7 +68,7 @@ above = 100 .unwrap(); let loaded = config::load(dir.path(), &[cfg_path.display().to_string()], &[], &[], &[]).unwrap(); - let th = gate_thresholds(&loaded.config); + let th = gate_thresholds(&loaded.config.language_config("base").unwrap()); // Built-in metric, no `[metrics]` info → `info` mirrors the gate (one tier). assert_eq!(th["hk"].warning, 500_000.0); @@ -87,6 +87,7 @@ fn assemble_level_synthesizes_default_spec_when_none() { // `files` Level, then layers the central metric/coupling specs over it. use code_ranker_plugin_api::graph::Graph; let custom = BTreeMap::new(); + let cfg = toml::Table::new(); let level = assemble_level( None, Graph::default(), @@ -95,6 +96,7 @@ fn assemble_level_synthesizes_default_spec_when_none() { BTreeMap::new(), &custom, "rust", + &cfg, &[], ); // An empty graph prunes every metric spec (no node carries it), so the @@ -124,6 +126,7 @@ fn assemble_level_keeps_grouping_with_a_function() { }), }; let custom = BTreeMap::new(); + let cfg = toml::Table::new(); let level = assemble_level( Some(spec), Graph::default(), @@ -132,47 +135,115 @@ fn assemble_level_keeps_grouping_with_a_function() { BTreeMap::new(), &custom, "rust", + &cfg, &[], ); let g = level.ui.grouping.expect("function grouping retained"); assert_eq!(g.function.as_deref(), Some("dir")); } +/// Single-marker detection cases: each marker file causes exactly the expected +/// plugin to be detected via `detect_all`. #[test] fn detect_plugin_by_single_marker() { + use code_ranker_plugin_api::plugin::PluginInput; let cases = vec![ ("Cargo.toml", "rust"), ("pyproject.toml", "python"), ("setup.py", "python"), - ("package.json", "javascript"), - ("tsconfig.json", "typescript"), + ("package.json", "js"), + ("tsconfig.json", "ts"), ]; + let empty_overrides = BTreeMap::new(); for (marker, expected) in cases { let d = tempfile::tempdir().unwrap(); fs::write(d.path().join(marker), "").unwrap(); - assert_eq!( - plugin::detect(d.path(), &PluginInput::default()).unwrap(), - expected, - "marker {marker}" + let eff_cfgs: BTreeMap = plugin::registry() + .iter() + .map(|p| { + ( + p.name().to_string(), + plugin::effective_plugin_config(p.name(), &empty_overrides), + ) + }) + .collect(); + let detected = plugin::detect_all(&eff_cfgs, d.path(), &PluginInput::default()); + assert!( + detected.contains(&expected.to_string()), + "marker {marker} should detect {expected}, got: {detected:?}" ); } } +/// `detect_all` returns an empty list on an empty directory, and multiple +/// results when both Cargo.toml and package.json are present — no error. #[test] -fn detect_plugin_errors_on_ambiguous_or_empty() { +fn detect_all_multi_and_empty() { + use code_ranker_plugin_api::plugin::PluginInput; + let empty_overrides = BTreeMap::new(); + + // Empty directory: no detections (previously an error, now just empty). + let empty = tempfile::tempdir().unwrap(); + let eff_cfgs: BTreeMap = plugin::registry() + .iter() + .map(|p| { + ( + p.name().to_string(), + plugin::effective_plugin_config(p.name(), &empty_overrides), + ) + }) + .collect(); + let detected = plugin::detect_all(&eff_cfgs, empty.path(), &PluginInput::default()); + assert!( + detected.is_empty(), + "empty directory → no detections (no error): {detected:?}" + ); + + // Two markers → two detections (not an error). let amb = tempfile::tempdir().unwrap(); fs::write(amb.path().join("Cargo.toml"), "").unwrap(); fs::write(amb.path().join("package.json"), "").unwrap(); - let err = format!( - "{:#}", - plugin::detect(amb.path(), &PluginInput::default()).unwrap_err() + let detected = plugin::detect_all(&eff_cfgs, amb.path(), &PluginInput::default()); + assert!( + detected.len() >= 2, + "two markers → two (or more) detections: {detected:?}" + ); + assert!( + detected.contains(&"rust".to_string()), + "rust detected: {detected:?}" + ); + assert!( + detected.contains(&"js".to_string()), + "javascript detected: {detected:?}" ); - assert!(err.contains("multiple"), "ambiguous error: {err}"); + // Sorted. + let mut sorted = detected.clone(); + sorted.sort_unstable(); + assert_eq!(detected, sorted, "detect_all output is sorted"); +} - let empty = tempfile::tempdir().unwrap(); - let err = format!( - "{:#}", - plugin::detect(empty.path(), &PluginInput::default()).unwrap_err() +/// Explicit `--plugins md` on an empty directory: the markdown plugin finds no +/// source, so every active language drops out and assembly has nothing to +/// snapshot — a hard, actionable error rather than an empty snapshot. +#[test] +fn analyze_directory_errors_when_all_plugins_are_empty() { + let dir = tempfile::tempdir().unwrap(); + let args = AnalyzeArgs { + input: dir.path().to_path_buf(), + plugins: vec!["md".into()], + config: vec![], + ignore_paths: vec![], + git_branch: None, + git_commit: None, + git_dirty_files: None, + git_origin: None, + }; + let err = analyze_directory(&args, &[], &[]) + .err() + .expect("empty analysis should error") + .to_string(); + assert!( + err.contains("produced empty graphs"), + "all-empty bail names the cause: {err}" ); - assert!(err.contains("no project marker"), "empty error: {err}"); } diff --git a/crates/code-ranker-cli/src/plugin/mod.rs b/crates/code-ranker-cli/src/plugin/mod.rs index 06c06203..6aa44bfd 100644 --- a/crates/code-ranker-cli/src/plugin/mod.rs +++ b/crates/code-ranker-cli/src/plugin/mod.rs @@ -3,9 +3,12 @@ //! `code-ranker-plugins` crate and are collected by `code_ranker_plugin_api::registry`. //! Everything here works only through the `LanguagePlugin` trait and the plugin's //! `name()`. Adding a language is a self-contained module in the plugins crate. +//! +//! Multiple plugins matching the auto-detect heuristics is NORMAL (e.g. a +//! project with both Rust sources and Markdown docs). `detect_all` returns all +//! matching plugins sorted; `resolve_plugins` applies the precedence chain. use anyhow::{Result, bail}; -use code_ranker_graph::version::CONFIG_VERSION; use code_ranker_graph::write_metrics; use code_ranker_plugin_api::{ graph::Graph, @@ -18,6 +21,12 @@ use code_ranker_plugin_api::{ use std::collections::BTreeMap; use std::path::Path; +mod resolve; +pub use resolve::{ + detect_all, effective_plugin_config, names_with_aliases, resolve_plugins, to_canonical, + validate_extension_uniqueness, +}; + /// Every self-registered language plugin (see `code_ranker_plugin_api::registry`). /// The CLI links the `code-ranker-plugins` crate (its `deep_merge` / `list_override` /// are used elsewhere), so every plugin's `inventory::submit!` is collected here. @@ -25,39 +34,38 @@ pub fn registry() -> Vec<&'static dyn LanguagePlugin> { code_ranker_plugin_api::registry() } -/// Comma-separated canonical plugin names (sorted for stable help/error output; -/// the registry's link order is not significant). -pub fn names() -> String { - let mut names: Vec<&str> = registry().iter().map(|p| p.name()).collect(); - names.sort_unstable(); - names.join(", ") -} - /// Parse the workspace with the named plugin at the `"files"` level, returning /// the structural graph and the plugin's level descriptors. -pub fn analyze(name: &str, workspace: &Path, input: &PluginInput) -> Result<(Graph, Vec)> { +/// `cfg` is the effective plugin config (static base ⊕ user overrides). +pub fn analyze( + name: &str, + cfg: &toml::Table, + workspace: &Path, + input: &PluginInput, +) -> Result<(Graph, Vec)> { let reg = registry(); match reg.iter().find(|p| p.name() == name) { Some(p) => { - let graph = p.analyze(workspace, input)?; - Ok((graph, p.levels())) + let graph = p.analyze(cfg, workspace, input)?; + Ok((graph, p.levels(cfg))) } - None => bail!("unknown plugin {name:?}; built-in plugins are: {}", names()), + None => bail!( + "unknown plugin {name:?}; built-in languages are: {}", + names_with_aliases() + ), } } /// Have the matching plugin **measure** its per-language complexity inputs, then /// write every metric (tier-1 + the tier-2 registry derivations) onto the graph's /// file nodes here, in the orchestrator. Returns the number of nodes annotated. -/// Measuring is a per-language concern owned by the plugin (no central -/// by-extension dispatcher); enrichment (`write_metrics`, which needs the metric -/// catalog) is central — so a plugin never depends on `code-ranker-graph`. -pub fn annotate_metrics(name: &str, graph: &mut Graph) -> usize { +/// `cfg` is the effective plugin config. +pub fn annotate_metrics(name: &str, cfg: &toml::Table, graph: &mut Graph) -> usize { let reg = registry(); let Some(p) = reg.iter().find(|p| p.name() == name) else { return 0; }; - let by_id: BTreeMap = p.metrics(graph).into_iter().collect(); + let by_id: BTreeMap = p.metrics(cfg, graph).into_iter().collect(); let mut annotated = 0; for node in &mut graph.nodes { if let Some(inputs) = by_id.get(&node.id) { @@ -72,12 +80,13 @@ pub fn annotate_metrics(name: &str, graph: &mut Graph) -> usize { /// unit), for the optional `functions` level, then write their metrics onto the /// returned nodes here. Called on the absolute-id graph; returns nodes whose /// `parent` is the file id. Empty when the plugin ships no function-level support. -pub fn function_units(name: &str, graph: &Graph) -> Vec { +/// `cfg` is the effective plugin config. +pub fn function_units(name: &str, cfg: &toml::Table, graph: &Graph) -> Vec { let reg = registry(); let Some(p) = reg.iter().find(|p| p.name() == name) else { return Vec::new(); }; - p.function_units(graph) + p.function_units(cfg, graph) .into_iter() .map(|(mut node, inputs)| { write_metrics(&mut node, &inputs); @@ -87,41 +96,52 @@ pub fn function_units(name: &str, graph: &Graph) -> Vec { } /// Tool/toolchain versions the matching plugin wants recorded in the snapshot. -pub fn versions(name: &str, workspace: &Path, input: &PluginInput) -> Vec<(String, String)> { +/// `cfg` is the effective plugin config. +pub fn versions( + name: &str, + cfg: &toml::Table, + workspace: &Path, + input: &PluginInput, +) -> Vec<(String, String)> { registry() .iter() .find(|p| p.name() == name) - .map(|p| p.versions(workspace, input)) + .map(|p| p.versions(cfg, workspace, input)) .unwrap_or_default() } /// Named external-path roots the matching plugin contributes for shortening node /// ids (e.g. Rust's `cargo` / `registry` / `rustup` / `rust-src`). Language /// knowledge lives in the plugin; the orchestrator only adds the generic -/// `target` root on top. -pub fn roots(name: &str, workspace: &Path) -> Vec<(String, String)> { +/// `target` root on top. `cfg` is the effective plugin config. +pub fn roots(name: &str, cfg: &toml::Table, workspace: &Path) -> Vec<(String, String)> { registry() .iter() .find(|p| p.name() == name) - .map(|p| p.roots(workspace)) + .map(|p| p.roots(cfg, workspace)) .unwrap_or_default() } /// The matching plugin's report-list overrides (table `columns` / card / JSON /// `stats`), applied by the orchestrator over the global catalog lists. -pub fn report_overrides(name: &str) -> code_ranker_plugin_api::report::ReportOverride { +/// `cfg` is the effective plugin config. +pub fn report_overrides( + name: &str, + cfg: &toml::Table, +) -> code_ranker_plugin_api::report::ReportOverride { registry() .iter() .find(|p| p.name() == name) - .map(|p| p.report_overrides()) + .map(|p| p.report_overrides(cfg)) .unwrap_or_default() } /// The matching plugin's Prompt-Generator principles (the common catalog plus any /// language-specific principles), built from its own config. -pub fn principles(name: &str, input: &PluginInput) -> Vec { +/// `cfg` is the effective plugin config. +pub fn principles(name: &str, cfg: &toml::Table, input: &PluginInput) -> Vec { match registry().iter().find(|p| p.name() == name) { - Some(p) => p.principles(input), + Some(p) => p.principles(cfg, input), None => Vec::new(), } } @@ -130,9 +150,10 @@ pub fn principles(name: &str, input: &PluginInput) -> Vec { /// dictionaries, built from config with **no analysis**. The `docs` command reads /// the `files` level to surface a language's own structural metrics (e.g. Rust's /// `unsafe`, `items`) without walking a source tree. -pub fn levels(name: &str) -> Vec { +/// `cfg` is the effective plugin config. +pub fn levels(name: &str, cfg: &toml::Table) -> Vec { match registry().iter().find(|p| p.name() == name) { - Some(p) => p.levels(), + Some(p) => p.levels(cfg), None => Vec::new(), } } @@ -140,77 +161,18 @@ pub fn levels(name: &str) -> Vec { /// Let the matching plugin refine the language-neutral default metric specs /// (e.g. add Rust-specific `#[cfg(test)]` nuance to LOC descriptions). The /// neutral catalog comes from `code-ranker-graph`; the plugin overrides only -/// what differs for its language. +/// what differs for its language. `cfg` is the effective plugin config. pub fn metric_specs( name: &str, + cfg: &toml::Table, defaults: BTreeMap, ) -> BTreeMap { match registry().iter().find(|p| p.name() == name) { - Some(p) => p.metric_specs(defaults), + Some(p) => p.metric_specs(cfg, defaults), None => defaults, } } -/// Auto-detect the plugin from workspace markers. Errors if none or more than -/// one matches. -pub fn detect(workspace: &Path, input: &PluginInput) -> Result { - let reg = registry(); - let found: Vec<&str> = reg - .iter() - .filter(|p| p.detect(workspace, input)) - .map(|p| p.name()) - .collect(); - match found.as_slice() { - [one] => Ok((*one).to_string()), - [] => bail!( - "could not auto-detect a plugin in {}: no project marker found — pass --plugin {}", - workspace.display(), - names() - ), - _ => bail!( - "ambiguous project in {}: markers for multiple plugins found ({}) — pass --plugin to choose", - workspace.display(), - found.join(", ") - ), - } -} - -/// Resolve the plugin name: explicit `--plugin` > config `plugin` > auto-detect. -/// A value of `auto` (or absence) triggers project-marker detection. Lives here, -/// with the registry and [`detect`], so plugin selection is one concern. -pub fn resolve_plugin( - arg: Option<&str>, - cfg: Option<&str>, - workspace: &Path, - config_file: Option<&str>, -) -> Result { - if let Some(p) = arg - && p != "auto" - { - return Ok(p.to_string()); - } - if let Some(p) = cfg - && p != "auto" - { - return Ok(p.to_string()); - } - // Auto-detect failed (no marker / ambiguous): append a config-aware way to pin - // the language, so the user isn't left with only `--plugin` on every run. - detect(workspace, &PluginInput::default()).map_err(|e| with_config_hint(e, config_file)) -} - -/// Augment a failed-detection error with how to pin the language in config: add -/// `plugin` to the discovered `code-ranker.toml`, or create one when none exists. -fn with_config_hint(e: anyhow::Error, config_file: Option<&str>) -> anyhow::Error { - let how = match config_file { - Some(path) => format!("add `plugin = \"\"` to {path}"), - None => format!( - "create a `code-ranker.toml` at the project root with:\n version = \"{CONFIG_VERSION}\"\n plugin = \"\"" - ), - }; - anyhow::anyhow!("{e}\n → or pin the language in config: {how}") -} - #[cfg(test)] #[path = "mod_test.rs"] mod tests; diff --git a/crates/code-ranker-cli/src/plugin/mod_test.rs b/crates/code-ranker-cli/src/plugin/mod_test.rs index def395d0..368405e7 100644 --- a/crates/code-ranker-cli/src/plugin/mod_test.rs +++ b/crates/code-ranker-cli/src/plugin/mod_test.rs @@ -54,15 +54,7 @@ fn every_registered_plugin_has_committed_goldens() { #[test] fn registry_holds_exactly_the_expected_plugins() { const EXPECTED: &[&str] = &[ - "c", - "cpp", - "csharp", - "go", - "javascript", - "markdown", - "python", - "rust", - "typescript", + "c", "cpp", "csharp", "go", "js", "md", "python", "rust", "ts", ]; let mut found: Vec<&str> = registry().iter().map(|p| p.name()).collect(); @@ -83,41 +75,182 @@ fn registry_holds_exactly_the_expected_plugins() { } } +/// `detect_all` returns a sorted list of matching plugins (multiple is NORMAL). #[test] -fn resolve_plugin_precedence_explicit_then_config_then_auto() { +fn detect_all_returns_sorted_multi_set() { let d = tempfile::tempdir().unwrap(); std::fs::write(d.path().join("pyproject.toml"), "").unwrap(); - assert_eq!( - resolve_plugin(Some("rust"), Some("javascript"), d.path(), None).unwrap(), - "rust", - "explicit --plugin wins" + let eff_cfgs: BTreeMap = registry() + .iter() + .map(|p| { + ( + p.name().to_string(), + effective_plugin_config(p.name(), &BTreeMap::new()), + ) + }) + .collect(); + let detected = detect_all(&eff_cfgs, d.path(), &PluginInput::default()); + assert!( + detected.contains(&"python".to_string()), + "python detected by pyproject.toml" ); - assert_eq!( - resolve_plugin(None, Some("rust"), d.path(), None).unwrap(), - "rust", - "config wins over auto-detect" + // Result is sorted. + let mut sorted = detected.clone(); + sorted.sort_unstable(); + assert_eq!(detected, sorted, "detect_all output is sorted"); +} + +/// `detect_all` returns an empty list when there are no markers (no error — +/// error is `resolve_plugins`'s job). +#[test] +fn detect_all_returns_empty_on_no_markers() { + let d = tempfile::tempdir().unwrap(); + let eff_cfgs: BTreeMap = registry() + .iter() + .map(|p| { + ( + p.name().to_string(), + effective_plugin_config(p.name(), &BTreeMap::new()), + ) + }) + .collect(); + let detected = detect_all(&eff_cfgs, d.path(), &PluginInput::default()); + assert!( + detected.is_empty(), + "empty directory should produce no detections, got: {detected:?}" ); - assert_eq!( - resolve_plugin(Some("auto"), None, d.path(), None).unwrap(), - "python", - "explicit auto -> detect" +} + +/// `resolve_plugins` precedence: console (`arg`) > config (`cfg_plugins`) > auto-detect. +#[test] +fn resolve_plugins_precedence() { + let d = tempfile::tempdir().unwrap(); + std::fs::write(d.path().join("pyproject.toml"), "").unwrap(); + + let eff_cfgs: BTreeMap = registry() + .iter() + .map(|p| { + ( + p.name().to_string(), + effective_plugin_config(p.name(), &BTreeMap::new()), + ) + }) + .collect(); + let input = PluginInput::default(); + + // Console --plugins wins over config plugins. + let result = resolve_plugins( + &["rust".to_string()], + &["javascript".to_string()], + &eff_cfgs, + d.path(), + &input, + None, + ) + .unwrap(); + assert_eq!(result, vec!["rust"], "console --plugins wins"); + + // Config plugins win over auto-detect. + let result = resolve_plugins( + &[], + &["rust".to_string()], + &eff_cfgs, + d.path(), + &input, + None, + ) + .unwrap(); + assert_eq!(result, vec!["rust"], "config plugins win over auto-detect"); + + // Auto-detect runs when neither console nor config provides a list. + let result = resolve_plugins(&[], &[], &eff_cfgs, d.path(), &input, None).unwrap(); + assert!( + result.contains(&"python".to_string()), + "auto-detect picks up pyproject.toml" ); - assert_eq!( - resolve_plugin(None, None, d.path(), None).unwrap(), - "python", - "no plugin -> detect" +} + +/// Language aliases resolve to the canonical name everywhere a language is named; +/// an unknown token is left untouched (a downstream lookup reports it). +#[test] +fn aliases_resolve_to_canonical() { + assert_eq!(to_canonical("javascript"), "js"); + assert_eq!(to_canonical("ts"), "ts", "canonical name is idempotent"); + assert_eq!(to_canonical("py"), "python"); + assert_eq!(to_canonical("rs"), "rust"); + assert_eq!(to_canonical("c#"), "csharp"); + assert_eq!(to_canonical("rust"), "rust", "canonical name is idempotent"); + assert_eq!(to_canonical("nope"), "nope", "unknown token is left as-is"); + + // `--plugins js,py` resolves to canonical names (→ canonical snapshot keys). + let d = tempfile::tempdir().unwrap(); + let result = resolve_plugins( + &["js".to_string(), "py".to_string()], + &[], + &BTreeMap::new(), + d.path(), + &PluginInput::default(), + None, + ) + .unwrap(); + assert_eq!(result, vec!["js", "python"]); +} + +/// `resolve_plugins` errors on zero detection (with a config hint). +#[test] +fn resolve_plugins_errors_on_zero_detection() { + let d = tempfile::tempdir().unwrap(); + let eff_cfgs: BTreeMap = registry() + .iter() + .map(|p| { + ( + p.name().to_string(), + effective_plugin_config(p.name(), &BTreeMap::new()), + ) + }) + .collect(); + + let with_cfg = resolve_plugins( + &[], + &[], + &eff_cfgs, + d.path(), + &PluginInput::default(), + Some("/proj/code-ranker.toml"), + ) + .unwrap_err(); + let msg = format!("{with_cfg:#}"); + assert!( + msg.contains("plugins = ["), + "zero-detect error should mention plugins = [...]: {msg}" + ); + assert!( + msg.contains("/proj/code-ranker.toml"), + "error should reference the config file: {msg}" + ); + + let no_cfg = + resolve_plugins(&[], &[], &eff_cfgs, d.path(), &PluginInput::default(), None).unwrap_err(); + assert!( + format!("{no_cfg:#}").contains("code-ranker.toml"), + "no-config case suggests creating a config: {no_cfg:#}" ); } #[test] fn levels_returns_the_spec_for_a_known_plugin_and_empty_for_unknown() { // A real plugin publishes its `files` level (no analysis); an unknown name is - // an empty list, not a panic. + // an empty list, not a panic. `levels` reads the plugin's effective config, so + // pass rust's real base config (an empty table would miss required vocab). + let cfg = effective_plugin_config("rust", &BTreeMap::new()); assert!( - levels("rust").iter().any(|l| l.name == "files"), + levels("rust", &cfg).iter().any(|l| l.name == "files"), "rust publishes a files level" ); - assert!(levels("nope").is_empty(), "unknown plugin → no levels"); + assert!( + levels("nope", &cfg).is_empty(), + "unknown plugin → no levels" + ); } #[test] @@ -127,22 +260,23 @@ fn unknown_plugin_accessors_degrade_gracefully() { let tmp = tempfile::tempdir().unwrap(); let input = PluginInput::default(); let mut graph = Graph::default(); + let cfg = toml::Table::new(); assert!( - analyze("nope", tmp.path(), &input).is_err(), + analyze("nope", &cfg, tmp.path(), &input).is_err(), "analyze with an unknown plugin errors" ); assert_eq!( - annotate_metrics("nope", &mut graph), + annotate_metrics("nope", &cfg, &mut graph), 0, "no metrics annotated for an unknown plugin" ); assert!( - function_units("nope", &graph).is_empty(), + function_units("nope", &cfg, &graph).is_empty(), "no function units for an unknown plugin" ); assert!( - principles("nope", &input).is_empty(), + principles("nope", &cfg, &input).is_empty(), "no principles for an unknown plugin" ); // `metric_specs` returns the defaults verbatim for an unknown plugin. @@ -152,7 +286,7 @@ fn unknown_plugin_accessors_degrade_gracefully() { )] .into_iter() .collect(); - let out = metric_specs("nope", defaults.clone()); + let out = metric_specs("nope", &cfg, defaults.clone()); assert!( out.contains_key("sloc"), "defaults passed through unchanged" @@ -160,20 +294,98 @@ fn unknown_plugin_accessors_degrade_gracefully() { assert_eq!(out.len(), defaults.len(), "no plugin refinement applied"); } +/// `effective_plugin_config` merges [languages.base] then [languages.]. #[test] -fn resolve_plugin_failure_points_at_config() { - // No marker resolves here, so the error guides the user to pin the language — - // into the discovered config when one exists, else by creating `code-ranker.toml`. - let d = tempfile::tempdir().unwrap(); - let with_cfg = - resolve_plugin(None, None, d.path(), Some("/proj/code-ranker.toml")).unwrap_err(); +fn effective_plugin_config_merges_base_then_lang() { + let mut overrides: BTreeMap = BTreeMap::new(); + + // A base layer that all languages inherit. + let mut base_table = toml::Table::new(); + base_table.insert( + "skip_dirs".to_string(), + toml::Value::Array(vec![toml::Value::String("vendor".to_string())]), + ); + overrides.insert("base".to_string(), base_table); + + // A rust-specific layer that overrides one key. + let mut rust_table = toml::Table::new(); + rust_table.insert( + "skip_dirs".to_string(), + toml::Value::Array(vec![toml::Value::String("target".to_string())]), + ); + overrides.insert("rust".to_string(), rust_table); + + let eff = effective_plugin_config("rust", &overrides); + // [languages.rust] completely replaced skip_dirs from [languages.base]. + let dirs = eff.get("skip_dirs").and_then(|v| v.as_array()).unwrap(); + assert!( + dirs.iter().any(|v| v.as_str() == Some("target")), + "rust layer should have 'target'" + ); + + // Python only inherits the base. + let python_eff = effective_plugin_config("python", &overrides); + let dirs = python_eff + .get("skip_dirs") + .and_then(|v| v.as_array()) + .unwrap(); assert!( - format!("{with_cfg:#}").contains("add `plugin = \"\"` to /proj/code-ranker.toml"), - "suggests editing the existing config: {with_cfg:#}" + dirs.iter().any(|v| v.as_str() == Some("vendor")), + "python should inherit base 'vendor'" ); - let no_cfg = resolve_plugin(None, None, d.path(), None).unwrap_err(); +} + +/// `validate_extension_uniqueness` passes when all extensions are disjoint. +#[test] +fn validate_extension_uniqueness_passes_when_disjoint() { + let mut eff_cfgs: BTreeMap = BTreeMap::new(); + let mut rs = toml::Table::new(); + rs.insert( + "extensions".to_string(), + toml::Value::Array(vec![toml::Value::String("rs".to_string())]), + ); + eff_cfgs.insert("rust".to_string(), rs); + let mut py = toml::Table::new(); + py.insert( + "extensions".to_string(), + toml::Value::Array(vec![toml::Value::String("py".to_string())]), + ); + eff_cfgs.insert("python".to_string(), py); + let active = vec!["rust".to_string(), "python".to_string()]; + assert!(validate_extension_uniqueness(&active, &eff_cfgs).is_ok()); +} + +/// `validate_extension_uniqueness` errors when two plugins share an extension. +#[test] +fn validate_extension_uniqueness_errors_on_conflict() { + let mut eff_cfgs: BTreeMap = BTreeMap::new(); + let mut p1 = toml::Table::new(); + p1.insert( + "extensions".to_string(), + toml::Value::Array(vec![toml::Value::String("ts".to_string())]), + ); + eff_cfgs.insert("typescript".to_string(), p1); + let mut p2 = toml::Table::new(); + p2.insert( + "extensions".to_string(), + toml::Value::Array(vec![toml::Value::String("ts".to_string())]), + ); + eff_cfgs.insert("javascript".to_string(), p2); + let active = vec!["typescript".to_string(), "javascript".to_string()]; + let err = validate_extension_uniqueness(&active, &eff_cfgs).unwrap_err(); + let msg = format!("{err:#}"); + assert!( + msg.contains("extension conflict") || msg.contains(".ts"), + "should mention the conflicting extension: {msg}" + ); +} + +/// `"base"` is NOT a registered plugin name — it is a reserved virtual key. +#[test] +fn base_is_not_a_registered_plugin() { + let found = registry().iter().any(|p| p.name() == "base"); assert!( - format!("{no_cfg:#}").contains("create a `code-ranker.toml`"), - "suggests creating a config: {no_cfg:#}" + !found, + "'base' must not be a real plugin name — it is a reserved virtual key for [languages.base]" ); } diff --git a/crates/code-ranker-cli/src/plugin/resolve.rs b/crates/code-ranker-cli/src/plugin/resolve.rs new file mode 100644 index 00000000..b0892533 --- /dev/null +++ b/crates/code-ranker-cli/src/plugin/resolve.rs @@ -0,0 +1,230 @@ +//! Plugin **selection policy**: building each language's effective config, +//! auto-detecting languages, resolving the active set (console > config > +//! auto-detect), and the one-file-one-language extension-uniqueness guard. This is +//! orchestration over the registry, kept apart from the thin per-plugin method +//! dispatch in [`super`] so neither concern carries the other's dependencies. + +use super::registry; +use anyhow::{Result, anyhow, bail}; +use code_ranker_graph::version::CONFIG_VERSION; +use code_ranker_plugin_api::{plugin::PluginInput, toml_merge::deep_merge}; +use std::collections::BTreeMap; +use std::path::Path; + +/// Resolve a user-supplied language token — a canonical plugin `name()` **or** one +/// of a plugin's declared `aliases` (e.g. `js` → `javascript`) — to the canonical +/// name. `None` when it matches neither. Aliases are read from each plugin's static +/// config (`aliases = [...]` in its `config.toml`); a canonical name wins outright. +pub fn canonical_name(token: &str) -> Option { + let reg = registry(); + if let Some(p) = reg.iter().find(|p| p.name() == token) { + return Some(p.name().to_string()); + } + reg.iter() + .find(|p| { + toml_string_list(&p.config(), "aliases") + .iter() + .any(|a| a == token) + }) + .map(|p| p.name().to_string()) +} + +/// Resolve an alias to its canonical name, leaving an unknown token untouched so a +/// downstream lookup reports it as unknown with the proper hint. Idempotent on an +/// already-canonical name. +pub fn to_canonical(token: &str) -> String { + canonical_name(token).unwrap_or_else(|| token.to_string()) +} + +/// Canonical names with their aliases, for "unknown language" error hints — +/// e.g. `c, cpp (c++, cxx), … , javascript (js), …` (sorted). +pub fn names_with_aliases() -> String { + let mut entries: Vec = registry() + .iter() + .map(|p| { + let aliases = toml_string_list(&p.config(), "aliases"); + if aliases.is_empty() { + p.name().to_string() + } else { + format!("{} ({})", p.name(), aliases.join(", ")) + } + }) + .collect(); + entries.sort_unstable(); + entries.join(", ") +} + +/// Read a top-level string array from a TOML table (e.g. `extensions = ["rs"]`). +/// Returns an empty `Vec` when the key is absent or is not a string array. +fn toml_string_list(cfg: &toml::Table, key: &str) -> Vec { + cfg.get(key) + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() +} + +/// Build the effective plugin config for `name`: +/// static plugin base (`plugin.config()`) +/// ⊕ user `[languages.base]` +/// ⊕ user `[languages.]` +/// +/// `lang_overrides` is `Config.languages` — the raw per-language tables already +/// carrying any `--config languages.*` edits. The merge uses `deep_merge` from +/// `code_ranker_plugin_api`, matching the rest of the config pipeline. +pub fn effective_plugin_config( + name: &str, + lang_overrides: &BTreeMap, +) -> toml::Table { + let base_cfg = registry() + .iter() + .find(|p| p.name() == name) + .map(|p| p.config()) + .unwrap_or_default(); + + // A `[plugins.]` block carries BOTH plugin-config keys (extensions, + // detect_markers, node_attributes, …) and orchestrator sections + // (ignore/rules/metrics/levels/report/principles). Only the former belong in the + // plugin's effective config; the orchestrator sections are read separately via + // `Config::language_config`, and some (`principles`) even have a conflicting + // shape here (the plugin's own `[[principles]]` is an array, the project's + // `[principles.]` a table), so merging them in would corrupt the plugin config. + let plugin_keys = |block: &toml::Table| -> toml::Table { + block + .iter() + .filter(|(k, _)| !crate::config::model::LANG_SECTION_KEYS.contains(&k.as_str())) + .map(|(k, v)| (k.clone(), v.clone())) + .collect() + }; + + let mut acc = base_cfg; + for key in ["base", name] { + if let Some(block) = lang_overrides.get(key) { + acc = deep_merge(acc, plugin_keys(block)); + } + } + acc +} + +/// All plugins whose `detect()` returns `true` under their effective config; +/// sorted alphabetically. Multiple matches are NORMAL (e.g. Rust + Markdown). +/// +/// `eff_cfgs` maps each registered plugin name to its pre-built effective config +/// (call `effective_plugin_config` for each registered plugin beforehand). +pub fn detect_all( + eff_cfgs: &BTreeMap, + workspace: &Path, + input: &PluginInput, +) -> Vec { + let reg = registry(); + let mut found: Vec = reg + .iter() + .filter(|p| { + let cfg = eff_cfgs + .get(p.name()) + .map(|t| t as &toml::Table) + .unwrap_or(&EMPTY_TABLE); + p.detect(cfg, workspace, input) + }) + .map(|p| p.name().to_string()) + .collect(); + found.sort_unstable(); + found +} + +/// An empty TOML table used as the fallback effective config when none is present. +static EMPTY_TABLE: std::sync::LazyLock = std::sync::LazyLock::new(toml::Table::new); + +/// Resolve the active plugins. +/// +/// Precedence (low → high; each level fully REPLACES the one below it): +/// 1. auto-detect (`detect_all`) — used only when neither config nor console pin the list. +/// 2. config `plugins` — replaces auto-detect. +/// 3. console `--plugins` (`arg`) — replaces config. +/// +/// An empty `detect_all` result (no markers found) → `Err` with a zero-detect message. +pub fn resolve_plugins( + arg: &[String], + cfg_plugins: &[String], + eff_cfgs: &BTreeMap, + workspace: &Path, + input: &PluginInput, + config_file: Option<&str>, +) -> Result> { + // Console wins outright. Resolve aliases (`js` → `javascript`) so the active + // set — and therefore the snapshot keys — are always canonical. + if !arg.is_empty() { + return Ok(arg.iter().map(|t| to_canonical(t)).collect()); + } + // Config wins over auto-detect. + if !cfg_plugins.is_empty() { + return Ok(cfg_plugins.iter().map(|t| to_canonical(t)).collect()); + } + // Auto-detect: error on empty result. + let detected = detect_all(eff_cfgs, workspace, input); + if detected.is_empty() { + let e = anyhow!( + "could not auto-detect any language in {}: no project markers found \ + (for C/C++ projects, no source files with the expected extensions were found)", + workspace.display() + ); + return Err(with_config_hint(e, config_file)); + } + Ok(detected) +} + +/// Startup guard: for the ACTIVE plugins, build a map `extension → [plugin names]` +/// from their effective configs; any extension claimed by more than one plugin is +/// an error (a file would be analysed twice, breaking the one-file-one-language +/// invariant). +pub fn validate_extension_uniqueness( + active: &[String], + eff_cfgs: &BTreeMap, +) -> Result<()> { + let mut ext_owners: BTreeMap> = BTreeMap::new(); + for name in active { + let cfg = eff_cfgs + .get(name) + .map(|t| t as &toml::Table) + .unwrap_or(&EMPTY_TABLE); + // Read the `extensions` list from the effective config (same key the + // plugins use in their own TOML). + let extensions = toml_string_list(cfg, "extensions"); + for ext in extensions { + ext_owners.entry(ext).or_default().push(name.clone()); + } + } + let conflicts: Vec = ext_owners + .iter() + .filter(|(_, owners)| owners.len() > 1) + .map(|(ext, owners)| format!(".{ext} claimed by: {}", owners.join(", "))) + .collect(); + if !conflicts.is_empty() { + bail!( + "extension conflict between active plugins — a file would be analysed by multiple \ + languages (breaking the one-file-one-language invariant):\n {}\n\ + Fix: adjust `extensions` in `[languages.]` or restrict `plugins = [\"...\"]`.", + conflicts.join("\n ") + ); + } + Ok(()) +} + +/// Augment a failed-detection error with how to pin the languages in config. +fn with_config_hint(e: anyhow::Error, config_file: Option<&str>) -> anyhow::Error { + let how = match config_file { + Some(path) => format!( + "add `plugins = [\"\"]` to {path} \ + (run `code-ranker docs` for a list of built-in plugins)" + ), + None => format!( + "create a `code-ranker.toml` at the project root with:\n\ + \tversion = \"{CONFIG_VERSION}\"\n\ + \tplugins = [\"\"]" + ), + }; + anyhow!("{e}\n → or pin the language in config: {how}") +} diff --git a/crates/code-ranker-cli/src/recommend.rs b/crates/code-ranker-cli/src/recommend.rs index 9593ff2f..b8472240 100644 --- a/crates/code-ranker-cli/src/recommend.rs +++ b/crates/code-ranker-cli/src/recommend.rs @@ -1,4 +1,5 @@ -//! The recommendation engine behind the `prompt` and `scorecard` report formats. +//! The recommendation engine behind the `--prompt ` output and the `scorecard` +//! report format. //! //! It is the console counterpart of the HTML viewer's Prompt Generator: the same //! ranking (`reco_for` ≈ `recoFor` in `export-popup.js`) and the same Markdown @@ -12,8 +13,9 @@ //! frames it by the metric itself, a **principle** id (`LSP`) by that design //! principle. Resolution lives in [`resolve_focus`]. -use anyhow::{Result, bail}; +use anyhow::{Context, Result, bail}; use code_ranker_graph::level_graph::{CycleGroup, LevelGraph}; +use code_ranker_graph::snapshot::{LanguageSnapshot, Snapshot}; pub use code_ranker_plugin_api::Principle; use code_ranker_plugin_api::{ attrs::{AttrValue, ValueType}, @@ -22,6 +24,71 @@ use code_ranker_plugin_api::{ }; use std::collections::HashMap; +/// Select the `LanguageSnapshot` to use for recommendations. +/// +/// Resolution order: +/// 1. `--language` explicitly given → use that language or error. +/// 2. Single language → use it (no ambiguity). +/// 3. Multiple languages + `id` given → search all; if `id` matches exactly +/// one language, use it; if 2+ match → error listing them. +/// 4. Multiple languages + no `id` → use the first (BTreeMap order); this +/// path is taken only for scorecard/prompt without `--focus`. +pub fn resolve_language_snap<'a>( + snap: &'a Snapshot, + language: Option<&str>, + id: Option<&str>, +) -> Result<&'a LanguageSnapshot> { + // Explicit `--language` always wins. Resolve an alias (`js` → `javascript`) + // to the canonical key the snapshot stores under. + if let Some(lang) = language { + let canon = crate::plugin::to_canonical(lang); + return snap.languages.get(&canon).with_context(|| { + let available: Vec<&str> = snap.languages.keys().map(String::as_str).collect(); + format!( + "language {lang:?} not found in snapshot; available: {}", + available.join(", ") + ) + }); + } + + // Single language: no ambiguity. + if snap.languages.len() == 1 { + return Ok(snap.languages.values().next().expect("len==1")); + } + + // Multiple languages: try to resolve the id across all of them. + if let Some(focus_id) = id { + let matches: Vec<&str> = snap + .languages + .iter() + .filter_map(|(lang, ls)| { + // A match is: it is a principle id, or a metric key in the files level. + let is_principle = ls.principles.iter().any(|p| p.id == focus_id); + let is_metric = ls + .graphs + .get("files") + .is_some_and(|g| g.node_attributes.contains_key(focus_id)); + (is_principle || is_metric).then_some(lang.as_str()) + }) + .collect(); + + match matches.as_slice() { + [one] => return Ok(snap.languages.get(*one).expect("key from languages")), + [] => {} // fall through to first-language default + langs => anyhow::bail!( + "{focus_id:?} found in languages: {}; specify --language to disambiguate", + langs.join(", ") + ), + } + } + + // Fall back to the first language (BTreeMap order, deterministic). + snap.languages + .values() + .next() + .context("snapshot has no languages; regenerate the report with `code-ranker report`") +} + mod prompt; mod scorecard; @@ -266,7 +333,7 @@ pub fn reco_for<'a>(level: &'a LevelGraph, metric: &str) -> Reco<'a> { .then(bi.total_cmp(&ai)) }); // No configured threshold → no breaches (the metric still ranks for display, - // but never claims violations and so never wins `worst_principle`). + // but never claims violations and so never contributes to the scorecard counts). let (warning_count, info_count) = match th { Some(th) => ( sorted @@ -345,26 +412,6 @@ pub(super) fn tier_count(reco: &Reco, sev: Severity) -> usize { } } -/// The principle with the most violations: highest `warning` count, tie-broken by -/// `info` count, then by catalog order (the first principle wins on a tie). `None` -/// only if there are no principles. -pub fn worst_principle(level: &LevelGraph, principles: &[Principle]) -> Option { - let mut best: Option<(&Principle, usize, usize)> = None; - for p in principles { - let r = reco_for(level, &p.sort_metric); - // Strictly-greater so the FIRST principle wins on a tie (catalog order). - let better = match best { - None => true, - Some((_, bw, bi)) => (r.warning_count, r.info_count) > (bw, bi), - }; - if better { - best = Some((p, r.warning_count, r.info_count)); - } - } - best.map(|(p, _, _)| p.id.clone()) - .or_else(|| principles.first().map(|p| p.id.clone())) -} - /// Count of project source files in the level. pub(super) fn file_count(level: &LevelGraph) -> usize { level.nodes.iter().filter(|n| is_internal(n)).count() diff --git a/crates/code-ranker-cli/src/recommend/scorecard.rs b/crates/code-ranker-cli/src/recommend/scorecard.rs index 06a420ba..7f9a9e4a 100644 --- a/crates/code-ranker-cli/src/recommend/scorecard.rs +++ b/crates/code-ranker-cli/src/recommend/scorecard.rs @@ -88,7 +88,7 @@ fn node_breaches( /// Render the console triage scorecard: a per-principle table (warning/info /// counts + the worst module) followed by the worst modules overall, then a hint -/// pointing at the prompt for the worst principle. +/// pointing at `--prompt ` for a specific principle/metric. pub fn render_scorecard( plugin: &str, level: &LevelGraph, @@ -163,7 +163,9 @@ pub fn render_scorecard( } // ── Next-step hint ─────────────────────────────────────────────────────── - out.push_str("\n→ code-ranker report . --output.prompt.path=… --top 1\n"); + out.push_str( + "\n→ code-ranker report . --prompt (AI fix-prompt to stdout)\n", + ); Ok(out) } diff --git a/crates/code-ranker-cli/src/recommend_test.rs b/crates/code-ranker-cli/src/recommend_test.rs index 5050f616..33bedab0 100644 --- a/crates/code-ranker-cli/src/recommend_test.rs +++ b/crates/code-ranker-cli/src/recommend_test.rs @@ -102,40 +102,6 @@ fn reco_for_cycle_uses_cycle_members() { assert_eq!(r.sorted[0].id, "{target}/b.rs"); } -#[test] -fn worst_principle_picks_most_violations() { - let level = level_with(vec![file_node( - "{target}/a.rs", - &[ - ("hk", AttrValue::Float(2000.0)), - ("sloc", AttrValue::Int(10)), - ("cycle", AttrValue::Str("mutual".into())), - ], - )]); - let principles = vec![ - Principle { - id: "SRP".into(), - label: "SRP".into(), - title: "SRP — x".into(), - prompt: "p".into(), - doc_url: None, - sort_metric: "sloc".into(), - connections: vec![], - }, - Principle { - id: "ADP".into(), - label: "ADP".into(), - title: "ADP — x".into(), - prompt: "p".into(), - doc_url: None, - sort_metric: "cycle".into(), - connections: vec!["common".into()], - }, - ]; - // SRP: sloc 10 → 0 breaches; ADP: cycle → 1. ADP wins. - assert_eq!(worst_principle(&level, &principles).as_deref(), Some("ADP")); -} - #[test] fn compose_prompt_cycle_lists_modules_and_connections() { let mut level = level_with(vec![ @@ -383,7 +349,7 @@ fn scorecard_shows_principle_and_worst_modules() { "hk breach listed: {sc}" ); assert!( - sc.contains("→ code-ranker report . --output.prompt.path=… --top 1"), + sc.contains("→ code-ranker report . --prompt "), "next-step hint" ); } @@ -892,6 +858,198 @@ fn scorecard_focus_principle_shows_only_that_principle() { ); } +// ── resolve_language_snap ────────────────────────────────────────────────────── + +/// A `LanguageSnapshot` carrying the bits `resolve_language_snap` reads: the +/// `files` level (for the metric check) and the principle list (for the id check). +fn lang_snap(files: LevelGraph, principles: Vec) -> LanguageSnapshot { + let mut graphs = BTreeMap::new(); + graphs.insert("files".to_string(), files); + LanguageSnapshot { + graphs, + principles, + prompt: Default::default(), + } +} + +/// A `Snapshot` over the given (language → snapshot) pairs; everything else is the +/// minimum the resolver never reads. +fn snap_of(langs: Vec<(&str, LanguageSnapshot)>) -> Snapshot { + let languages: BTreeMap = + langs.into_iter().map(|(k, v)| (k.to_string(), v)).collect(); + let plugins: Vec = languages.keys().cloned().collect(); + Snapshot::new(code_ranker_graph::snapshot::SnapshotInit { + command: "report".into(), + workspace: ".".into(), + target: ".".into(), + plugins, + config_file: None, + versions: BTreeMap::new(), + roots: BTreeMap::new(), + git: None, + timings: vec![], + languages, + }) +} + +/// Explicit `--language` wins and resolves an alias (`py` → `python`) to the +/// canonical key the snapshot stores under. +#[test] +fn resolve_language_snap_explicit_resolves_alias() { + let snap = snap_of(vec![ + ("rust", lang_snap(level_with(vec![]), vec![])), + ( + "python", + lang_snap(level_with(vec![]), vec![srp_principle()]), + ), + ]); + let ls = resolve_language_snap(&snap, Some("py"), None).unwrap(); + assert_eq!(ls.principles[0].id, "SRP", "py alias resolved to python"); +} + +/// An explicit language not in the snapshot is fatal, and the error lists what IS +/// available. +#[test] +fn resolve_language_snap_explicit_unknown_lists_available() { + let snap = snap_of(vec![ + ("rust", lang_snap(level_with(vec![]), vec![])), + ("python", lang_snap(level_with(vec![]), vec![])), + ]); + let err = resolve_language_snap(&snap, Some("go"), None) + .unwrap_err() + .to_string(); + assert!( + err.contains("\"go\" not found"), + "names the bad language: {err}" + ); + assert!( + err.contains("python") && err.contains("rust"), + "lists available languages: {err}" + ); +} + +/// A single-language snapshot resolves to it regardless of `id`/`language`. +#[test] +fn resolve_language_snap_single_language_ignores_id() { + let snap = snap_of(vec![( + "rust", + lang_snap(level_with(vec![]), vec![srp_principle()]), + )]); + let ls = resolve_language_snap(&snap, None, Some("anything")).unwrap(); + assert_eq!(ls.principles[0].id, "SRP", "the only language is used"); +} + +/// With multiple languages and an `id` that is a principle in exactly one, that +/// language is chosen. +#[test] +fn resolve_language_snap_id_matches_one_principle() { + let snap = snap_of(vec![ + // bare files level → no metric keys, so only the principle can match + ("python", lang_snap(LevelGraph::default(), vec![])), + ( + "rust", + lang_snap(LevelGraph::default(), vec![srp_principle()]), + ), + ]); + let ls = resolve_language_snap(&snap, None, Some("SRP")).unwrap(); + assert_eq!( + ls.principles[0].id, "SRP", + "matched the principle's language" + ); +} + +/// An `id` that is a metric key in one language's `files` level (but not the other) +/// selects that language — the `is_metric` branch. +#[test] +fn resolve_language_snap_id_matches_metric_in_one() { + let snap = snap_of(vec![ + // `level_with` seeds `hk`/`sloc` node attributes → the metric lives here + ("rust", lang_snap(level_with(vec![]), vec![])), + ("python", lang_snap(LevelGraph::default(), vec![])), + ]); + let ls = resolve_language_snap(&snap, None, Some("hk")).unwrap(); + assert!( + ls.graphs["files"].node_attributes.contains_key("hk"), + "picked the language whose files level carries the metric" + ); +} + +/// An `id` present in more than one language is ambiguous → fatal, listing them. +#[test] +fn resolve_language_snap_id_in_multiple_errors() { + let snap = snap_of(vec![ + ( + "python", + lang_snap(LevelGraph::default(), vec![srp_principle()]), + ), + ( + "rust", + lang_snap(LevelGraph::default(), vec![srp_principle()]), + ), + ]); + let err = resolve_language_snap(&snap, None, Some("SRP")) + .unwrap_err() + .to_string(); + assert!( + err.contains("\"SRP\" found in languages"), + "ambiguity: {err}" + ); + assert!( + err.contains("python") && err.contains("rust"), + "lists the matching languages: {err}" + ); + assert!(err.contains("--language"), "hints the disambiguator: {err}"); +} + +/// Multiple languages and an `id` that matches none → fall back to the first +/// language in BTreeMap order (deterministic). +#[test] +fn resolve_language_snap_id_none_match_falls_to_first() { + let snap = snap_of(vec![ + ( + "python", + lang_snap(LevelGraph::default(), vec![srp_principle()]), + ), + ( + "rust", + lang_snap(LevelGraph::default(), vec![adp_principle()]), + ), + ]); + // "ZZZ" is neither a principle nor a metric anywhere → first key wins. + let ls = resolve_language_snap(&snap, None, Some("ZZZ")).unwrap(); + assert_eq!(ls.principles[0].id, "SRP", "python sorts before rust"); +} + +/// Multiple languages and no `id` → the first language (BTreeMap order). +#[test] +fn resolve_language_snap_no_id_uses_first() { + let snap = snap_of(vec![ + ( + "rust", + lang_snap(LevelGraph::default(), vec![adp_principle()]), + ), + ( + "python", + lang_snap(LevelGraph::default(), vec![srp_principle()]), + ), + ]); + let ls = resolve_language_snap(&snap, None, None).unwrap(); + assert_eq!(ls.principles[0].id, "SRP", "python sorts before rust"); +} + +/// A snapshot with zero languages is a fatal, actionable error. +#[test] +fn resolve_language_snap_empty_errors() { + let snap = snap_of(vec![]); + let err = resolve_language_snap(&snap, None, None) + .unwrap_err() + .to_string(); + assert!( + err.contains("no languages"), + "explains the empty snapshot: {err}" + ); +} + /// `--top 1` reduces the prompt to a single focus module: the connections are /// rendered in the abbreviated single-focus form — an `out` edge as "line N" /// (use-site in the focus file, named above) and an `in` edge as `dependant:line`. diff --git a/crates/code-ranker-cli/src/report.rs b/crates/code-ranker-cli/src/report.rs index 8cece36b..dea33a6e 100644 --- a/crates/code-ranker-cli/src/report.rs +++ b/crates/code-ranker-cli/src/report.rs @@ -1,5 +1,6 @@ //! `report` — analyze (or read) the input and write artifacts: JSON snapshot, -//! HTML viewer (diff with `--baseline`), and the advisory prompt / scorecard. +//! HTML viewer (diff with `--baseline`), and the advisory scorecard. The named +//! AI fix-prompt is available via `--prompt ` (printed to stdout). use crate::analyze::{analyze_input, load_snapshot_any}; use crate::cli::AnalyzeArgs; @@ -15,17 +16,15 @@ pub(crate) struct ReportOutputs { pub(crate) html: bool, pub(crate) sarif: bool, pub(crate) codequality: bool, - pub(crate) prompt: bool, pub(crate) scorecard: bool, pub(crate) json_path: Option, pub(crate) html_path: Option, pub(crate) sarif_path: Option, pub(crate) codequality_path: Option, - pub(crate) prompt_path: Option, pub(crate) scorecard_path: Option, } -/// Recommendation knobs for the `prompt` / `scorecard` formats. +/// Recommendation knobs for the `scorecard` format and the `--prompt ` output. pub(crate) struct ReportReco { /// Focus the `scorecard` / `prompt` on one axis — a metric / rule id (`hk`, /// `threshold.file.hk`) or a principle id (`LSP`). Resolved against both. @@ -37,6 +36,10 @@ pub(crate) struct ReportReco { pub(crate) index: Option, /// `--prompt `: print the named principle/metric prompt to stdout and exit. pub(crate) prompt_id: Option, + /// `--language `: which language's graphs/principles/prompt to use for + /// recommendations and scorecard. Required when a `--focus`/`--prompt` id is + /// present in more than one language and the choice would be ambiguous. + pub(crate) language: Option, } /// `report` — analyze (or read) the input and write artifacts. Which formats are @@ -58,44 +61,35 @@ pub(crate) fn run_report( let html_path = out.html_path.as_deref(); let sarif_path = out.sarif_path.as_deref(); let codequality_path = out.codequality_path.as_deref(); - let prompt_path = out.prompt_path.as_deref(); let scorecard_path = out.scorecard_path.as_deref(); - // The recommendation formats are flag-only (no `[output.]` config) and - // are never part of the default set. - let want_prompt = out.prompt || prompt_path.is_some(); + // The scorecard format is flag-only (no `[output.]` config) and is never + // part of the default set. let want_scorecard = out.scorecard || scorecard_path.is_some(); // Validate the recommendation knobs before any analysis runs. `--index` is // intentionally unsupported — complain with a hint rather than a bare clap - // "unknown flag" — and the other knobs only make sense for prompt/scorecard. + // "unknown flag" — and the other knobs only make sense for the scorecard (the + // `--prompt ` path is handled by `run_direct` above and never reaches here). if reco.index.is_some() { anyhow::bail!( "--index is not supported; use --top N instead (--top 1 = the single worst module)" ); } - if !want_prompt - && !want_scorecard + if !want_scorecard && (reco.focus.is_some() || !reco.focus_path.is_empty() || !reco.severity.is_empty() || reco.top.is_some()) { anyhow::bail!( - "--focus/--focus-path/--severity/--top apply only with --output.prompt or --output.scorecard" + "--focus/--focus-path/--severity/--top apply only with --output.scorecard (for a fix-prompt use --prompt )" ); } // `--severity` steers the scorecard only (tiers are a triage concern). if !reco.severity.is_empty() && !want_scorecard { anyhow::bail!("--severity applies only to --output.scorecard"); } - // The prompt is auto-targeted at the single worst module: it requires exactly - // `--top 1` (prompts are long; for a broader view use --output.scorecard). - if want_prompt && reco.top != Some(1) { - anyhow::bail!( - "--output.prompt requires --top 1 (it is auto-targeted at the single worst module)" - ); - } let a = analyze_input(args, &[], &[])?; @@ -106,13 +100,7 @@ pub(crate) fn run_report( let mut want_html = want_format(out.html, html_path, &a.output.html); let want_sarif = want_format(out.sarif, sarif_path, &a.output.sarif); let want_codequality = want_format(out.codequality, codequality_path, &a.output.codequality); - if !want_json - && !want_html - && !want_sarif - && !want_codequality - && !want_prompt - && !want_scorecard - { + if !want_json && !want_html && !want_sarif && !want_codequality && !want_scorecard { want_json = true; want_html = true; } @@ -166,20 +154,10 @@ pub(crate) fn run_report( .or(a.output.sarif.path.as_deref()) .expect("output.sarif.path from built-in defaults"); let dest = render_name(tpl, &target, commit, generated_at); - // Diagnostic copy (rule titles / descriptions) is resolved from the - // reported snapshot's `files`-level specs — no rule prose in the CLI. - let files = a.snapshot.graphs.get("files"); - let empty_na: std::collections::BTreeMap< - String, - code_ranker_plugin_api::level::AttributeSpec, - > = Default::default(); - let empty_ck: std::collections::BTreeMap< - String, - code_ranker_plugin_api::level::CycleKindSpec, - > = Default::default(); - let na = files.map(|g| &g.node_attributes).unwrap_or(&empty_na); - let ck = files.map(|g| &g.cycle_kinds).unwrap_or(&empty_ck); - let mut sarif = crate::check::sarif_document(&a.violations, na, ck); + // Diagnostic copy is merged across all languages (last-wins); same + // strategy as `check`'s human/GitHub/prompt diagnostics. + let (na, ck) = crate::check::merged_specs_pub(&a.snapshot.languages); + let mut sarif = crate::check::sarif_document(&a.violations, &na, &ck); sarif.push('\n'); write_artifact(&dest, &sarif, "sarif")?; } @@ -198,26 +176,13 @@ pub(crate) fn run_report( write_artifact(&dest, &cq, "codequality")?; } - if want_prompt || want_scorecard { - // A `--output..path` flag wins; otherwise the default template comes - // from the merged config (always present from the built-in defaults). - let prompt_tpl = prompt_path - .or(a.output.prompt.path.as_deref()) - .expect("output.prompt.path from built-in defaults"); + if want_scorecard { + // A `--output.scorecard.path` flag wins; otherwise the default template + // comes from the merged config (always present from the built-in defaults). let scorecard_tpl = scorecard_path .or(a.output.scorecard.path.as_deref()) .expect("output.scorecard.path from built-in defaults"); - write_recommendations( - snap, - &reco, - want_prompt, - want_scorecard, - prompt_tpl, - scorecard_tpl, - &target, - commit, - generated_at, - )?; + write_scorecard(snap, &reco, scorecard_tpl, &target, commit, generated_at)?; } Ok(()) @@ -231,30 +196,30 @@ fn run_direct(args: &AnalyzeArgs, reco: &ReportReco) -> Result<()> { let a = analyze_input(args, &[], &[])?; let snap = &a.snapshot; - // `--prompt `: compose the named principle/metric prompt (same builder as - // `--output.prompt`, but for the id you name, to stdout). + // `--prompt `: compose the named principle/metric prompt to stdout. let id = reco.prompt_id.as_deref().expect("prompt_id is set"); - let level = snap + let lang_snap = recommend::resolve_language_snap(snap, reco.language.as_deref(), Some(id))?; + let level = lang_snap .graphs .get("files") .context("snapshot has no `files` level to build a prompt from")?; - let focus = recommend::resolve_focus(level, &snap.principles, id)?; + let focus = recommend::resolve_focus(level, &lang_snap.principles, id)?; let synth; // holds the metric-lens principle for the borrow below let (principles_for_prompt, principle_id): (&[recommend::Principle], String) = match &focus { recommend::Focus::Metric(m) => { synth = [recommend::synth_metric_principle( level, - &snap.principles, + &lang_snap.principles, m, )]; (&synth, m.clone()) } - recommend::Focus::Principle(pid) => (&snap.principles, pid.clone()), + recommend::Focus::Principle(pid) => (&lang_snap.principles, pid.clone()), }; let md = recommend::compose_prompt( level, principles_for_prompt, - &snap.prompt, + &lang_snap.prompt, &principle_id, recommend::Severity::Auto, reco.top, @@ -264,24 +229,21 @@ fn run_direct(args: &AnalyzeArgs, reco: &ReportReco) -> Result<()> { Ok(()) } -/// Write the recommendation artifacts (`prompt` / `scorecard`) for the analyzed -/// snapshot. Both read the `files` level. `--focus` picks the lens: a metric frames -/// the output by the metric itself, a principle by that design principle; without -/// it the prompt auto-targets the worst-violating principle and the scorecard spans -/// all. -#[allow(clippy::too_many_arguments)] -fn write_recommendations( +/// Write the console-triage `scorecard` artifact for the analyzed snapshot. Reads +/// the `files` level of the selected language. `--focus` picks the lens (a metric +/// frames it by the metric itself, a principle by that design principle); without +/// it the scorecard spans every principle. +fn write_scorecard( snap: &Snapshot, reco: &ReportReco, - want_prompt: bool, - want_scorecard: bool, - prompt_tpl: &str, scorecard_tpl: &str, target: &Path, commit: Option<&str>, generated_at: DateTime, ) -> Result<()> { - let level = snap + let lang_snap = + recommend::resolve_language_snap(snap, reco.language.as_deref(), reco.focus.as_deref())?; + let level = lang_snap .graphs .get("files") .context("snapshot has no `files` level to build recommendations from")?; @@ -291,47 +253,10 @@ fn write_recommendations( let focus = reco .focus .as_deref() - .map(|n| recommend::resolve_focus(level, &snap.principles, n)) + .map(|n| recommend::resolve_focus(level, &lang_snap.principles, n)) .transpose()?; - if want_prompt { - // Metric focus frames the prompt by a synthesized metric "principle" (no SOLID - // principle); a principle focus targets that principle; no focus auto-targets - // the worst-violating principle. `--top 1` is validated up front, so `Auto` - // tier is irrelevant. - let synth; // holds the metric-lens principle, if any, for the borrow below - let (principles_for_prompt, principle_id): (&[recommend::Principle], String) = match &focus - { - Some(recommend::Focus::Metric(m)) => { - synth = [recommend::synth_metric_principle( - level, - &snap.principles, - m, - )]; - (&synth, m.clone()) - } - Some(recommend::Focus::Principle(id)) => (&snap.principles, id.clone()), - None => ( - &snap.principles, - recommend::worst_principle(level, &snap.principles) - .context("no principles in the snapshot to recommend from")?, - ), - }; - let md = recommend::compose_prompt( - level, - principles_for_prompt, - &snap.prompt, - &principle_id, - recommend::Severity::Auto, - reco.top, - &reco.focus_path, - )?; - let dest = render_name(prompt_tpl, target, commit, generated_at) - .replace("{principle}", &principle_id); - write_artifact(&dest, &md, "prompt")?; - } - - if want_scorecard { + { let severities = if reco.severity.is_empty() { vec![recommend::Severity::Warning, recommend::Severity::Info] } else { @@ -340,10 +265,18 @@ fn write_recommendations( .map(|s| recommend::parse_severity(s)) .collect::>>()? }; + // Show the plugin name(s) in the scorecard header — join all active + // plugins, or just the selected language when one is picked. + let plugin_label = reco.language.as_deref().unwrap_or_else(|| { + snap.plugins + .first() + .map(String::as_str) + .unwrap_or("unknown") + }); let txt = recommend::render_scorecard( - &snap.plugin, + plugin_label, level, - &snap.principles, + &lang_snap.principles, &severities, reco.top, focus.as_ref(), diff --git a/crates/code-ranker-cli/src/templates.rs b/crates/code-ranker-cli/src/templates.rs index 97ea9389..d1a7df17 100644 --- a/crates/code-ranker-cli/src/templates.rs +++ b/crates/code-ranker-cli/src/templates.rs @@ -93,11 +93,11 @@ fn doc_rel_path( }) } -/// Extract the `/.md` tail of a corpus URL (`…/languages/base/HK.md` +/// Extract the `/.md` tail of a corpus URL (`…/plugins/base/HK.md` /// → `base/HK.md`). A `remediation` value is free prose ending in the URL, so the -/// match is anchored on the `/languages/` segment, not the string start. +/// match is anchored on the `/plugins/` segment, not the string start. fn url_tail(url: &str) -> Option { - let after = url.rsplit_once("/languages/")?.1; + let after = url.rsplit_once("/plugins/")?.1; // Stop at whitespace in case the URL is embedded in a sentence. Some(after.split_whitespace().next()?.to_string()) } @@ -275,6 +275,7 @@ pub(crate) fn ai_doc() -> Result { /// still raw — `ai::fill_select` fills them). That is the product description, the /// command list, and the plugin-setup template; the analysis playbook + catalog after /// it stay withheld until a language is chosen. +#[allow(dead_code)] // exercised by tests; the `ai` command now requires a language. pub(crate) fn ai_doc_intro() -> Result { let md = corpus_doc("base/AI.md").context("base/AI.md is not embedded in this build")?; let head = md.split(AI_SELECT_END).next().unwrap_or(md); @@ -344,13 +345,13 @@ fn lang_display(lang: &str) -> &str { match lang { "rust" => "Rust", "python" => "Python", - "typescript" => "TypeScript", - "javascript" => "JavaScript", + "ts" => "TypeScript", + "js" => "JavaScript", "go" => "Go", "c" => "C", "cpp" => "C++", "csharp" => "C#", - "markdown" => "Markdown", + "md" => "Markdown", other => other, } } diff --git a/crates/code-ranker-cli/src/templates_test.rs b/crates/code-ranker-cli/src/templates_test.rs index 992bdcbc..f5d89035 100644 --- a/crates/code-ranker-cli/src/templates_test.rs +++ b/crates/code-ranker-cli/src/templates_test.rs @@ -1,19 +1,23 @@ use super::*; use code_ranker_graph::level_graph::LevelGraph; -use code_ranker_graph::snapshot::Snapshot; +use code_ranker_graph::snapshot::{LanguageSnapshot, Snapshot, SnapshotInit}; use code_ranker_plugin_api::Principle; use code_ranker_plugin_api::attrs::ValueType; use code_ranker_plugin_api::level::AttributeSpec; use std::collections::BTreeMap; +/// The single language a test snapshot carries. +const LANG: &str = "rust"; + /// Test shim mirroring the old snapshot-based `resolve_doc`: pulls the principles /// and `files`-level node-attribute specs out of a test snapshot and feeds the /// spec-based core. Keeps these tests reading naturally now that production /// resolves docs from config/plugin specs (no snapshot) via `docs`. fn resolve_doc(s: &Snapshot, templates: &TemplatesConfig, id: &str) -> Result { + let lang = &s.languages[LANG]; resolve_doc_from_specs( - &s.principles, - &s.graphs["files"].node_attributes, + &lang.principles, + &lang.graphs["files"].node_attributes, templates, id, ) @@ -28,20 +32,27 @@ fn snap(principles: Vec, files_attrs: BTreeMap }; let mut graphs = BTreeMap::new(); graphs.insert("files".to_string(), files); - Snapshot::new( - "report".into(), - ".".into(), - ".".into(), - "rust".into(), - None, - BTreeMap::new(), - BTreeMap::new(), - None, - vec![], - graphs, - principles, - Default::default(), - ) + let mut languages = BTreeMap::new(); + languages.insert( + LANG.to_string(), + LanguageSnapshot { + graphs, + principles, + prompt: Default::default(), + }, + ); + Snapshot::new(SnapshotInit { + command: "report".into(), + workspace: ".".into(), + target: ".".into(), + plugins: vec![LANG.to_string()], + config_file: None, + versions: BTreeMap::new(), + roots: BTreeMap::new(), + git: None, + timings: vec![], + languages, + }) } fn principle(id: &str, doc_url: &str) -> Principle { @@ -65,10 +76,7 @@ fn metric_spec() -> AttributeSpec { #[test] fn resolve_doc_serves_base_fallback() { let s = snap( - vec![principle( - "SRP", - "https://x/blob/main/languages/base/SRP.md", - )], + vec![principle("SRP", "https://x/blob/main/plugins/base/SRP.md")], BTreeMap::new(), ); let doc = resolve_doc(&s, &TemplatesConfig::default(), "SRP").unwrap(); @@ -80,10 +88,7 @@ fn resolve_doc_assembles_a_language_manifest() { // rust/ADP.md is a manifest (``), so the resolved doc // is the composition over base/ADP.md, not the raw manifest text. let s = snap( - vec![principle( - "ADP", - "https://x/blob/main/languages/rust/ADP.md", - )], + vec![principle("ADP", "https://x/blob/main/plugins/rust/ADP.md")], BTreeMap::new(), ); let doc = resolve_doc(&s, &TemplatesConfig::default(), "ADP").unwrap(); @@ -112,10 +117,7 @@ fn resolve_doc_manifest_uses_base_override_when_present() { .insert("base".to_string(), base_overrides); let s = snap( - vec![principle( - "ADP", - "https://x/blob/main/languages/rust/ADP.md", - )], + vec![principle("ADP", "https://x/blob/main/plugins/rust/ADP.md")], BTreeMap::new(), ); let doc = resolve_doc(&s, &templates, "ADP").unwrap(); @@ -139,10 +141,7 @@ fn resolve_doc_override_wins_verbatim() { templates.languages.insert("rust".to_string(), srp); let s = snap( - vec![principle( - "SRP", - "https://x/blob/main/languages/rust/SRP.md", - )], + vec![principle("SRP", "https://x/blob/main/plugins/rust/SRP.md")], BTreeMap::new(), ); let doc = resolve_doc(&s, &templates, "SRP").unwrap(); @@ -154,10 +153,7 @@ fn resolve_doc_cycle_resolves_to_adp() { // `cycle` is ADP's metric lens (not a node attribute), so `--doc cycle` serves // the ADP doc — resolved through the ADP principle, same as `--doc ADP`. let s = snap( - vec![principle( - "ADP", - "https://x/blob/main/languages/rust/ADP.md", - )], + vec![principle("ADP", "https://x/blob/main/plugins/rust/ADP.md")], BTreeMap::new(), ); let doc = resolve_doc(&s, &TemplatesConfig::default(), "cycle").unwrap(); @@ -207,15 +203,13 @@ fn doc_rel_path_serves_lang_override_for_a_metric_doc() { let mut attrs = BTreeMap::new(); attrs.insert("hk".to_string(), metric_spec()); let s = snap( - vec![principle( - "ADP", - "https://x/blob/main/languages/rust/ADP.md", - )], + vec![principle("ADP", "https://x/blob/main/plugins/rust/ADP.md")], attrs, ); - let na = &s.graphs["files"].node_attributes; + let lang = &s.languages[LANG]; + let na = &lang.graphs["files"].node_attributes; assert_eq!( - doc_rel_path(&s.principles, na, "HK"), + doc_rel_path(&lang.principles, na, "HK"), Some("rust/HK.md".to_string()) ); } @@ -223,10 +217,7 @@ fn doc_rel_path_serves_lang_override_for_a_metric_doc() { #[test] fn resolve_doc_unknown_id_errors() { let s = snap( - vec![principle( - "SRP", - "https://x/blob/main/languages/base/SRP.md", - )], + vec![principle("SRP", "https://x/blob/main/plugins/base/SRP.md")], BTreeMap::new(), ); let err = resolve_doc(&s, &TemplatesConfig::default(), "ZZZ").unwrap_err(); @@ -254,13 +245,13 @@ fn corpus_is_embedded_and_keyed_by_rel_path() { #[test] fn url_tail_extracts_corpus_path() { assert_eq!( - url_tail("https://x/blob/main/languages/base/HK.md").as_deref(), + url_tail("https://x/blob/main/plugins/base/HK.md").as_deref(), Some("base/HK.md") ); assert_eq!( - url_tail("Download from https://x/main/languages/rust/SRP.md now").as_deref(), + url_tail("Download from https://x/main/plugins/rust/SRP.md now").as_deref(), Some("rust/SRP.md"), - "anchored on /languages/, trailing prose trimmed" + "anchored on /plugins/, trailing prose trimmed" ); assert_eq!(url_tail("https://x/elsewhere/HK.md"), None); } @@ -277,10 +268,7 @@ fn resolve_doc_ai_index_expands_tldr_marker() { // The AI overview resolves by filename fallback, and its // `` marker expands to the per-doc catalog. let s = snap( - vec![principle( - "ADP", - "https://x/blob/main/languages/rust/ADP.md", - )], + vec![principle("ADP", "https://x/blob/main/plugins/rust/ADP.md")], BTreeMap::new(), ); let doc = resolve_doc(&s, &TemplatesConfig::default(), "AI").unwrap(); diff --git a/crates/code-ranker-cli/tests/e2e.rs b/crates/code-ranker-cli/tests/e2e.rs index 351b0cd1..3cf668b5 100644 --- a/crates/code-ranker-cli/tests/e2e.rs +++ b/crates/code-ranker-cli/tests/e2e.rs @@ -239,7 +239,7 @@ fn assert_sample_matches(lang: &str) { /// Run `report` on a language's `sample/` with extra args, capturing stdout and /// stderr (instead of comparing a golden file). Used for the recommendation -/// formats (`scorecard` / `prompt`), which stream to stdout. +/// outputs (`--output.scorecard` / `--prompt `), which stream to stdout. fn run_report_capture(lang: &str, extra: &[&str]) -> (bool, String, String) { let sample = sample_dir(lang); // Run from a throwaway cwd. A `report` with no explicit json/html path falls back @@ -335,11 +335,11 @@ fn rust_sample_check_sarif() { let fp = r["partialFingerprints"]["codeRankerRuleLocation/v1"] .as_str() .unwrap_or_else(|| panic!("result has a versioned partial fingerprint: {r}")); - // The fingerprint is `rule:location` — it encodes the rule id and the - // file uri but never the line, so a shift does not reopen the finding. + // The fingerprint is `language:rule:location` — it encodes the language, + // rule id and file uri but never the line, so a shift does not reopen it. let rule = r["ruleId"].as_str().expect("ruleId"); assert!( - fp.starts_with(&format!("{rule}:")), + fp.contains(&format!("{rule}:")), "fingerprint encodes the rule id: {fp}" ); if let Some(uri) = r["locations"][0]["physicalLocation"]["artifactLocation"]["uri"].as_str() @@ -431,7 +431,8 @@ fn rust_sample_check_github_annotations() { fn rust_sample_check_suggest_config() { let (_ok, stdout, _e) = run_check_capture("rust", &["--suggest-config"]); assert!( - stdout.contains("[rules.cycles]") && stdout.contains("[rules.thresholds.file]"), + stdout.contains("[plugins.base.rules.cycles]") + && stdout.contains("[plugins.base.rules.thresholds.file]"), "suggested config blocks: {stdout}" ); assert!( @@ -564,49 +565,23 @@ fn rust_sample_prompt_flag_targets_metric_lens() { ); } -/// `docs ai` with **no resolvable plugin** (an empty directory — no markers) exits -/// `0` and prints the brief intro plus how to select a plugin, **withholding** the -/// principle/metric catalog until a language is chosen. +/// `docs ai` **without a language** is an error: `ai` is a subject, and docs are +/// per-language, so the language must come first (`docs ai`). #[test] -fn ai_unresolved_omits_catalog_and_shows_plugin_setup() { - let dir = std::env::temp_dir().join("cr-e2e-ai-unresolved"); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); +fn ai_without_language_errors() { let res = Command::new(env!("CARGO_BIN_EXE_code-ranker")) - .current_dir(&dir) + .current_dir(sample_dir("rust")) .args(["docs", "ai"]) .output() .expect("spawn docs ai"); assert!( - res.status.success(), - "docs ai must exit 0 even with no plugin: {}", - String::from_utf8_lossy(&res.stderr) - ); - let stdout = String::from_utf8_lossy(&res.stdout); - assert!( - stdout.contains("code-ranker — AI agent skill"), - "brief product intro present: {stdout}" - ); - assert!( - stdout.contains("## Commands") - && stdout.contains("**`help`**") - && stdout.contains("**`report"), - "lists the main commands (check/report/docs/help)" - ); - assert!( - stdout.contains("## Select a language") && stdout.contains("--plugin"), - "tells the user how to select a plugin" - ); - // The doc template's placeholders are filled (live plugin list + version), not leaked. - assert!( - stdout.contains("rust") - && !stdout.contains("{plugins}") - && !stdout.contains("{config_version}"), - "Select-a-language placeholders are substituted: {stdout}" + !res.status.success(), + "docs ai (no language) must exit non-zero" ); + let stderr = String::from_utf8_lossy(&res.stderr); assert!( - !stdout.contains("## Principles & metrics") && !stdout.contains("### ADP"), - "catalog withheld until a plugin is resolved: {stdout}" + stderr.contains("docs ai") || stderr.contains("not a language"), + "points the user at the per-language form: {stderr}" ); } @@ -616,7 +591,7 @@ fn ai_unresolved_omits_catalog_and_shows_plugin_setup() { fn ai_resolved_prints_full_catalog_without_setup() { let res = Command::new(env!("CARGO_BIN_EXE_code-ranker")) .current_dir(sample_dir("rust")) - .args(["docs", "ai"]) + .args(["docs", "rust", "ai"]) .output() .expect("spawn docs ai"); assert!( @@ -738,54 +713,21 @@ fn rust_sample_scorecard_triage() { "the two cycle members are listed as cycle breaches: {stdout}" ); assert!( - stdout.contains("--output.prompt.path=… --top 1"), - "next-step hint points at the auto-prompt: {stdout}" + stdout.contains("--prompt "), + "next-step hint points at the --prompt flag: {stdout}" ); } -/// With no `--principle`, the prompt auto-picks the worst-violating principle (ADP -/// here) and lists the worst cycle's members + their connections — the same -/// Markdown the HTML viewer's Prompt Generator emits. The 3-node `chain` SCC -/// outranks the 2-node `a ⇄ b` mutual, so it is the cycle shown. -#[test] -fn rust_sample_prompt_auto_picks_worst_principle() { - let (ok, stdout, stderr) = - run_report_capture("rust", &["--output.prompt.path=stdout", "--top", "1"]); - assert!(ok, "prompt run failed: {stderr}"); - assert!( - stdout.starts_with("# ADP — Acyclic Dependencies Principle"), - "auto-picked ADP as the title heading: {stdout}" - ); - assert!( - stdout.contains("## Modules in a dependency cycle"), - "cycle-modules section" - ); - assert!( - stdout.contains("- `src/chain/one.rs`") - && stdout.contains("- `src/chain/two.rs`") - && stdout.contains("- `src/chain/three.rs`"), - "the worst cycle (3-node chain) members listed with cleaned paths: {stdout}" - ); - assert!( - stdout.contains("## Connections — common"), - "ADP pre-selects the `common` connection set" - ); - assert!( - stdout.contains(".code-ranker/-ADP.md"), - "save-report instruction carries the principle id: {stdout}" - ); -} - -/// `report --prompt ` prints the named principle's prompt to stdout directly -/// (the explicit counterpart of `--output.prompt`, which auto-targets the worst), -/// honouring `--top`. Unlike `--output.prompt` it does NOT require `--top 1`. +/// `report --prompt ` prints the named principle's prompt to stdout directly, +/// honouring `--top`. It composes the same Markdown the HTML viewer's Prompt +/// Generator emits. #[test] fn rust_sample_prompt_flag_targets_named_principle() { let (ok, stdout, stderr) = run_report_capture("rust", &["--prompt", "SRP", "--top", "3"]); assert!(ok, "--prompt run failed: {stderr}"); assert!( stdout.starts_with("# SRP — Single Responsibility Principle"), - "named principle prompt, not the auto-worst: {stdout}" + "named principle prompt: {stdout}" ); assert!( stdout.contains("## Summary") && stdout.contains("## Task"), @@ -793,40 +735,30 @@ fn rust_sample_prompt_flag_targets_named_principle() { ); } -/// `--output.prompt --focus ` frames the auto-targeted prompt through the -/// metric lens (a synthesized metric-principle), titled by the metric rather than a -/// SOLID principle — the `Focus::Metric` arm of the `--output.prompt` builder. +/// `--prompt ` that pre-selects a connection set (ADP → `common`) lists +/// the worst cycle's members + their connections. The 3-node `chain` SCC outranks +/// the 2-node `a ⇄ b` mutual, so it is the cycle shown. #[test] -fn rust_sample_output_prompt_focus_metric_uses_metric_lens() { - let (ok, stdout, stderr) = run_report_capture( - "rust", - &["--output.prompt.path=stdout", "--focus", "HK", "--top", "1"], +fn rust_sample_prompt_flag_lists_cycle_and_connections() { + let (ok, stdout, stderr) = run_report_capture("rust", &["--prompt", "ADP", "--top", "1"]); + assert!(ok, "--prompt ADP run failed: {stderr}"); + assert!( + stdout.starts_with("# ADP — Acyclic Dependencies Principle"), + "ADP as the title heading: {stdout}" ); - assert!(ok, "focus-metric prompt failed: {stderr}"); assert!( - stdout.starts_with("# HK — God-object risk"), - "metric-lens prompt titled by the metric: {stdout}" + stdout.contains("## Modules in a dependency cycle"), + "cycle-modules section" ); -} - -/// `--output.prompt --focus ` targets that named principle instead of -/// the auto-worst — the `Focus::Principle` arm of the `--output.prompt` builder. -#[test] -fn rust_sample_output_prompt_focus_principle_targets_it() { - let (ok, stdout, stderr) = run_report_capture( - "rust", - &[ - "--output.prompt.path=stdout", - "--focus", - "SRP", - "--top", - "1", - ], + assert!( + stdout.contains("- `src/chain/one.rs`") + && stdout.contains("- `src/chain/two.rs`") + && stdout.contains("- `src/chain/three.rs`"), + "the worst cycle (3-node chain) members listed with cleaned paths: {stdout}" ); - assert!(ok, "focus-principle prompt failed: {stderr}"); assert!( - stdout.starts_with("# SRP — Single Responsibility Principle"), - "principle-focused prompt, not the auto-worst: {stdout}" + stdout.contains("## Connections — common"), + "ADP pre-selects the `common` connection set" ); } @@ -838,7 +770,7 @@ fn rust_sample_docs_subject_prints_embedded_markdown() { let run = |subject: &str| -> (bool, String) { let res = Command::new(env!("CARGO_BIN_EXE_code-ranker")) .current_dir(sample_dir("rust")) - .args(["docs", subject]) + .args(["docs", "rust", subject]) .output() .expect("spawn docs"); ( @@ -896,37 +828,28 @@ fn rust_sample_docs_subject_prints_embedded_markdown() { assert!( stdout4.contains("Unknown docs subject `nope`") && stdout4.contains("principles — SOLID") - && stdout4.contains("Call `docs`"), + && stdout4.contains("Call `docs rust`"), "catalog shown for an unknown subject: {stdout4}" ); } -/// `docs` is strictly per-language: with no plugin resolvable (an empty directory — -/// no markers), every subject but `ai` fails with the same diagnostic `check` / -/// `report` give, pointing the user at `--plugin`. +/// `docs` is strictly per-language: a subject given without a language (e.g. +/// `docs metrics`) errors and points the user at the `docs ` form. #[test] -fn docs_requires_a_resolved_plugin() { - let dir = std::env::temp_dir().join("cr-e2e-docs-no-plugin"); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); +fn docs_subject_without_language_errors() { let res = Command::new(env!("CARGO_BIN_EXE_code-ranker")) - .current_dir(&dir) + .current_dir(sample_dir("rust")) .args(["docs", "metrics"]) .output() .expect("spawn docs metrics"); assert!( !res.status.success(), - "docs with no resolvable plugin must exit non-zero" + "docs with a subject but no language must exit non-zero" ); let stderr = String::from_utf8_lossy(&res.stderr); assert!( - stderr.contains("--plugin"), - "error points the user at --plugin: {stderr}" - ); - // With no config present, the error also says to create a `code-ranker.toml`. - assert!( - stderr.contains("code-ranker.toml") && stderr.contains("plugin ="), - "error suggests pinning the plugin in config: {stderr}" + stderr.contains("not a language") && stderr.contains("docs "), + "error points the user at the per-language form: {stderr}" ); // The error is printed once (our stamped `error:` line) — the runtime does not // also emit its own `Error:` line. @@ -934,13 +857,6 @@ fn docs_requires_a_resolved_plugin() { !stderr.contains("Error:"), "error is not double-printed: {stderr}" ); - // `docs ai` still works there — it prints the intro on how to pick a plugin. - let ai = Command::new(env!("CARGO_BIN_EXE_code-ranker")) - .current_dir(&dir) - .args(["docs", "ai"]) - .output() - .expect("spawn docs ai"); - assert!(ai.status.success(), "docs ai succeeds with no plugin"); } /// `--focus ` frames the scorecard by that metric. `--focus cycle` @@ -1038,23 +954,10 @@ fn rust_sample_check_prompt_format() { ); } -/// `--output.prompt` is auto-targeted at the single worst module, so it requires -/// exactly `--top 1`; without it the run errors with a pointer to the scorecard. -#[test] -fn rust_sample_prompt_requires_top1() { - let (ok, _stdout, stderr) = run_report_capture("rust", &["--output.prompt.path=stdout"]); - assert!(!ok, "prompt without --top 1 must fail"); - assert!( - stderr.contains("--output.prompt requires --top 1"), - "actionable error: {stderr}" - ); -} - /// `--index` is rejected with a hint to use `--top`. #[test] fn rust_sample_report_rejects_index() { - let (ok, _stdout, stderr) = - run_report_capture("rust", &["--output.prompt.path=stdout", "--index", "0"]); + let (ok, _stdout, stderr) = run_report_capture("rust", &["--output.scorecard", "--index", "0"]); assert!(!ok, "--index must fail"); assert!( stderr.contains("--index is not supported") && stderr.contains("--top"), @@ -1062,13 +965,13 @@ fn rust_sample_report_rejects_index() { ); } -/// The recommendation knobs only apply with a `prompt` / `scorecard` format. +/// The recommendation knobs only apply with the `scorecard` format. #[test] fn rust_sample_report_rejects_stray_reco_flags() { let (ok, _stdout, stderr) = run_report_capture("rust", &["--focus", "hk"]); - assert!(!ok, "--focus without a prompt/scorecard format must fail"); + assert!(!ok, "--focus without a scorecard format must fail"); assert!( - stderr.contains("apply only with --output.prompt or --output.scorecard"), + stderr.contains("apply only with --output.scorecard"), "actionable error: {stderr}" ); } @@ -1085,12 +988,12 @@ fn python_sample_matches_golden() { #[test] fn javascript_sample_matches_golden() { - assert_sample_matches("javascript"); + assert_sample_matches("js"); } #[test] fn typescript_sample_matches_golden() { - assert_sample_matches("typescript"); + assert_sample_matches("ts"); } #[test] @@ -1118,7 +1021,7 @@ fn csharp_sample_matches_golden() { // by its golden but is NOT in `LANGS` (the all-central-metrics invariant). #[test] fn markdown_sample_matches_golden() { - assert_sample_matches("markdown"); + assert_sample_matches("md"); } /// Read a committed golden SARIF document for a language's `check` output. @@ -1172,12 +1075,12 @@ fn python_sample_check_sarif_matches_golden() { #[test] fn javascript_sample_check_sarif_matches_golden() { - assert_check_sarif_matches_golden("javascript"); + assert_check_sarif_matches_golden("js"); } #[test] fn typescript_sample_check_sarif_matches_golden() { - assert_check_sarif_matches_golden("typescript"); + assert_check_sarif_matches_golden("ts"); } /// `check --output-format codequality` must match the committed golden for the @@ -1213,25 +1116,16 @@ fn python_sample_check_codequality_matches_golden() { #[test] fn javascript_sample_check_codequality_matches_golden() { - assert_check_codequality_matches_golden("javascript"); + assert_check_codequality_matches_golden("js"); } #[test] fn typescript_sample_check_codequality_matches_golden() { - assert_check_codequality_matches_golden("typescript"); + assert_check_codequality_matches_golden("ts"); } /// Every language whose golden is committed. -const LANGS: &[&str] = &[ - "rust", - "python", - "javascript", - "typescript", - "go", - "c", - "cpp", - "csharp", -]; +const LANGS: &[&str] = &["rust", "python", "js", "ts", "go", "c", "cpp", "csharp"]; /// Central metrics (`metric_specs` + `coupling_specs`) the analyzer does NOT /// produce for a given language, so they are legitimately absent from that @@ -1242,18 +1136,7 @@ const LANGS: &[&str] = &[ const COVERAGE_EXCEPTIONS: &[(&str, &[&str])] = &[ // `tloc` is genuinely 0 for non-Rust: only the Rust pass strips `#[cfg(test)]` // items, so there are no test lines to count elsewhere. - ( - "tloc", - &[ - "python", - "javascript", - "typescript", - "go", - "c", - "cpp", - "csharp", - ], - ), + ("tloc", &["python", "js", "ts", "go", "c", "cpp", "csharp"]), // C has no closures/lambdas, so the `closures` counter is always 0. ("closures", &["c"]), ]; @@ -1267,7 +1150,13 @@ fn is_excepted(metric: &str, lang: &str) -> bool { /// True if `metric` is non-zero on at least one internal (non-external) file node /// of this golden. fn metric_present(golden: &Value, metric: &str) -> bool { - golden["graphs"]["files"]["nodes"] + // Each golden carries a single language; read its files-level nodes. + golden["languages"] + .as_object() + .expect("languages object") + .values() + .next() + .expect("one language")["graphs"]["files"]["nodes"] .as_array() .expect("nodes array") .iter() @@ -1352,7 +1241,7 @@ fn user_defined_metric_is_computed_and_emitted() { std::fs::write( p.join("code-ranker.toml"), vcfg( - "[metrics.comment_ratio]\n\ + "[plugins.base.metrics.comment_ratio]\n\ formula_cel = \"sloc > 0.0 ? cloc / sloc * 100.0 : 0.0\"\n\ label = \"Comments %\"\n\ direction = \"higher_better\"\n\ @@ -1366,7 +1255,7 @@ fn user_defined_metric_is_computed_and_emitted() { .env("CARGO_NET_OFFLINE", "true") .arg("report") .arg(".") - .arg("--plugin") + .arg("--plugins") .arg("python") .arg("--config") .arg(p.join("code-ranker.toml")) @@ -1376,7 +1265,7 @@ fn user_defined_metric_is_computed_and_emitted() { assert!(status.success(), "report should succeed"); let v: Value = serde_json::from_str(&std::fs::read_to_string(&out).unwrap()).unwrap(); - let files = &v["graphs"]["files"]; + let files = &v["languages"]["python"]["graphs"]["files"]; assert!( files["node_attributes"]["comment_ratio"].is_object(), "the user metric must appear in node_attributes (renders as a column)" @@ -1411,7 +1300,7 @@ fn user_defined_aggregate_lands_in_stats() { std::fs::write( p.join("code-ranker.toml"), vcfg( - "[metrics.cyc_mean]\n\ + "[plugins.base.metrics.cyc_mean]\n\ scope = \"graph\"\n\ formula_cel = \"agg('cyclomatic', 'avg', 'not_empty')\"\n", ), @@ -1423,7 +1312,7 @@ fn user_defined_aggregate_lands_in_stats() { .env("CARGO_NET_OFFLINE", "true") .arg("report") .arg(".") - .arg("--plugin") + .arg("--plugins") .arg("python") .arg("--config") .arg(p.join("code-ranker.toml")) @@ -1433,7 +1322,7 @@ fn user_defined_aggregate_lands_in_stats() { assert!(status.success(), "report should succeed"); let v: Value = serde_json::from_str(&std::fs::read_to_string(&out).unwrap()).unwrap(); - let stats = &v["graphs"]["files"]["stats"]; + let stats = &v["languages"]["python"]["graphs"]["files"]["stats"]; assert!( stats.get("cyc_mean").is_some(), "graph-scope aggregate must appear in stats: {stats}" @@ -1461,7 +1350,7 @@ fn functions_level_is_opt_in() { .env("CARGO_NET_OFFLINE", "true") .arg("report") .arg(".") - .arg("--plugin") + .arg("--plugins") .arg("python") .arg("--config") .arg(p.join("code-ranker.toml")) @@ -1475,13 +1364,13 @@ fn functions_level_is_opt_in() { // Off by default → only the files level. let off = run(""); assert!( - off["graphs"]["functions"].is_null(), + off["languages"]["python"]["graphs"]["functions"].is_null(), "functions level must be opt-in" ); // On → a functions level with per-function nodes. - let on = run("[levels]\nfunctions = true\n"); - let fns = &on["graphs"]["functions"]; + let on = run("[plugins.base.levels]\nfunctions = true\n"); + let fns = &on["languages"]["python"]["graphs"]["functions"]; assert!(fns.is_object(), "functions level present when enabled"); let nodes = fns["nodes"].as_array().expect("function nodes"); let f = nodes.iter().find(|n| n["name"] == "f").expect("function f"); @@ -1502,7 +1391,7 @@ fn empty_metric_warns_on_stderr() { std::fs::write(p.join("m.py"), "def f(x):\n return x\n").unwrap(); std::fs::write( p.join("code-ranker.toml"), - vcfg("[metrics.bad]\nformula_cel = \"slocc / 100.0\"\n"), // `slocc` is a typo for `sloc` + vcfg("[plugins.base.metrics.bad]\nformula_cel = \"slocc / 100.0\"\n"), // `slocc` is a typo for `sloc` ) .unwrap(); let out = Command::new(env!("CARGO_BIN_EXE_code-ranker")) @@ -1510,7 +1399,7 @@ fn empty_metric_warns_on_stderr() { .env("CARGO_NET_OFFLINE", "true") .arg("report") .arg(".") - .arg("--plugin") + .arg("--plugins") .arg("python") .arg("--config") .arg(p.join("code-ranker.toml")) @@ -1528,11 +1417,10 @@ fn empty_metric_warns_on_stderr() { ); } -/// Bare `docs` (no subject) prints the subject catalog and exits `0` — the catalog -/// *is* the help. Run from the Rust sample so a plugin auto-resolves (every subject -/// but `ai` is strictly per-language). +/// Bare `docs` (no language) lists the project's detected languages and how to +/// drill in, and exits `0`. Run from the Rust sample so `rust` auto-detects. #[test] -fn docs_bare_prints_the_catalog_and_exits_zero() { +fn docs_bare_lists_languages_and_exits_zero() { let res = Command::new(env!("CARGO_BIN_EXE_code-ranker")) .current_dir(sample_dir("rust")) .arg("docs") @@ -1545,9 +1433,33 @@ fn docs_bare_prints_the_catalog_and_exits_zero() { ); let stdout = String::from_utf8_lossy(&res.stdout); assert!( - stdout.contains("code-ranker docs "), - "catalog header present: {stdout}" + stdout.contains("plugins (languages):") && stdout.contains("base"), + "single language list with base: {stdout}" ); + assert!( + stdout.contains("rust — detected in this project"), + "annotates the detected language: {stdout}" + ); + assert!( + stdout.contains("code-ranker docs "), + "shows how to drill into a language: {stdout}" + ); +} + +/// `docs ` (a language, no subject) prints that language's subject catalog. +#[test] +fn docs_language_only_prints_the_catalog() { + let res = Command::new(env!("CARGO_BIN_EXE_code-ranker")) + .current_dir(sample_dir("rust")) + .args(["docs", "rust"]) + .output() + .expect("spawn docs rust"); + assert!( + res.status.success(), + "docs must exit 0: {}", + String::from_utf8_lossy(&res.stderr) + ); + let stdout = String::from_utf8_lossy(&res.stdout); assert!( stdout.contains("principles") && stdout.contains("ADP"), "principles group + a member listed: {stdout}" @@ -1559,7 +1471,7 @@ fn docs_bare_prints_the_catalog_and_exits_zero() { fn docs_principles_index_lists_every_principle() { let res = Command::new(env!("CARGO_BIN_EXE_code-ranker")) .current_dir(sample_dir("rust")) - .args(["docs", "principles"]) + .args(["docs", "rust", "principles"]) .output() .expect("spawn docs principles"); assert!(res.status.success(), "docs principles failed"); @@ -1577,7 +1489,7 @@ fn docs_principles_index_lists_every_principle() { fn docs_unknown_subject_prints_catalog_and_fails() { let res = Command::new(env!("CARGO_BIN_EXE_code-ranker")) .current_dir(sample_dir("rust")) - .args(["docs", "no-such-subject"]) + .args(["docs", "rust", "no-such-subject"]) .output() .expect("spawn docs"); assert!(!res.status.success(), "unknown subject must exit non-zero"); @@ -1588,8 +1500,9 @@ fn docs_unknown_subject_prints_catalog_and_fails() { ); } -/// `report --export-full-config PATH` writes the merged `[project]` + `[plugin]` -/// config and runs no analysis (the `Some(path)` arm in `main`). +/// `report --export-full-config PATH` writes the merged `[project]` + one +/// `[languages.]` per registered language and runs no analysis (the +/// `Some(path)` arm in `main`). #[test] fn report_export_full_config_writes_both_sections() { let sample = sample_dir("rust"); @@ -1612,7 +1525,7 @@ fn report_export_full_config_writes_both_sections() { ); let body = std::fs::read_to_string(&out).expect("config dump written"); assert!( - body.contains("[project]") && body.contains("[plugin]"), + body.contains("[project]") && body.contains("[languages.rust]"), "both sections present: {body}" ); } @@ -1665,3 +1578,84 @@ fn report_fails_when_no_plugin_resolves() { "error points at pinning a language: {stderr}" ); } + +/// Multi-language run: a workspace with both JavaScript (`package.json` marker + +/// `.js`) and Markdown (`.md`) is analysed in ONE pass — `plugins` lists both +/// (sorted) and `languages` carries a graph for each. (find 1 / step 8) +#[test] +fn multi_language_run_covers_every_detected_language() { + let dir = tempfile::tempdir().expect("temp dir"); + let p = dir.path(); + std::fs::write(p.join("package.json"), "{ \"name\": \"x\" }\n").unwrap(); + std::fs::write( + p.join("index.js"), + "import './util.js';\nexport const a = 1;\n", + ) + .unwrap(); + std::fs::write(p.join("util.js"), "export const b = 2;\n").unwrap(); + std::fs::write(p.join("README.md"), "# Title\n\nSee [docs](./guide.md).\n").unwrap(); + std::fs::write(p.join("guide.md"), "# Guide\n\nBody.\n").unwrap(); + let out = p.join("out.json"); + // Run from the temp dir (so no repo config is discovered) → auto-detect all. + let status = Command::new(env!("CARGO_BIN_EXE_code-ranker")) + .current_dir(p) + .env("CARGO_NET_OFFLINE", "true") + .arg("report") + .arg(".") + .arg(format!("--output.json.path={}", out.display())) + .status() + .expect("spawn code-ranker"); + assert!(status.success(), "multi-language report should succeed"); + + let v: Value = serde_json::from_str(&std::fs::read_to_string(&out).unwrap()).unwrap(); + assert_eq!( + v["plugins"].as_array().unwrap(), + &vec![Value::from("js"), Value::from("md")], + "both languages active and sorted" + ); + assert!( + v["languages"]["js"]["graphs"]["files"]["nodes"] + .as_array() + .is_some_and(|n| !n.is_empty()), + "js graph present and non-empty" + ); + assert!( + v["languages"]["md"]["graphs"]["files"]["nodes"] + .as_array() + .is_some_and(|n| !n.is_empty()), + "md graph present and non-empty" + ); +} + +/// A language whose marker is present but that yields NO nodes (here JavaScript: +/// `package.json` exists but there are no `.js` files) is dropped from the active +/// set — only the language that produced a graph remains. (find 2 / step 8) +#[test] +fn empty_graph_language_is_dropped() { + let dir = tempfile::tempdir().expect("temp dir"); + let p = dir.path(); + // package.json makes JavaScript DETECT, but there are no .js files to analyse. + std::fs::write(p.join("package.json"), "{ \"name\": \"x\" }\n").unwrap(); + std::fs::write(p.join("README.md"), "# Title\n\nBody.\n").unwrap(); + let out = p.join("out.json"); + let status = Command::new(env!("CARGO_BIN_EXE_code-ranker")) + .current_dir(p) + .env("CARGO_NET_OFFLINE", "true") + .arg("report") + .arg(".") + .arg(format!("--output.json.path={}", out.display())) + .status() + .expect("spawn code-ranker"); + assert!(status.success(), "report should succeed"); + + let v: Value = serde_json::from_str(&std::fs::read_to_string(&out).unwrap()).unwrap(); + assert_eq!( + v["plugins"].as_array().unwrap(), + &vec![Value::from("md")], + "the empty-graph language (js) is dropped" + ); + assert!( + v["languages"].get("js").is_none(), + "dropped language has no snapshot entry" + ); +} diff --git a/crates/code-ranker-graph/src/lib.rs b/crates/code-ranker-graph/src/lib.rs index 416d1af5..b2e77460 100644 --- a/crates/code-ranker-graph/src/lib.rs +++ b/crates/code-ranker-graph/src/lib.rs @@ -41,7 +41,7 @@ pub use checks::{CheckCompileError, CheckDef, CheckHit, CompiledCheck, GraphView pub use registry::{Engine, MetricDef, Populations, RegistryError, Scope, apply_to_node}; pub use relativize::{relativize_graph, relativize_level}; pub use serialize::{to_canonical_string, to_canonical_string_pretty}; -pub use snapshot::{GitInfo, Snapshot, StageTime}; +pub use snapshot::{GitInfo, LanguageSnapshot, Snapshot, SnapshotInit, StageTime}; pub use stats::compute_stats; // The coupling/cycle attribute specs (`fan_in` / `fan_out` / `fan_out_external` / diff --git a/crates/code-ranker-graph/src/snapshot.rs b/crates/code-ranker-graph/src/snapshot.rs index 0a1c11c3..b5cc0d0b 100644 --- a/crates/code-ranker-graph/src/snapshot.rs +++ b/crates/code-ranker-graph/src/snapshot.rs @@ -1,9 +1,10 @@ //! The serializable analysis artifact ([`Snapshot`]) and its header types //! ([`GitInfo`], [`StageTime`]). //! -//! Shape (schema version `"2"`): the snapshot keeps the historical header -//! (workspace/target/plugin/roots/versions/git/timings) and carries a `graphs` -//! map `level_name -> LevelGraph`. The per-level payload lives in +//! Shape (schema version `"5"`): the snapshot keeps the historical header +//! (workspace/target/plugins/roots/versions/git/timings) and carries a `languages` +//! map `lang_name -> LanguageSnapshot`, each of which holds the per-language +//! graphs, principles, and prompt template. The per-level payload lives in //! [`crate::level_graph`]; canonical serialization in [`crate::serialize`]; id //! relativization in [`crate::relativize`]. @@ -27,6 +28,23 @@ pub struct StageTime { pub detail: String, } +/// Per-language analysis output stored inside the snapshot. +/// +/// Each active language plugin contributes one entry in [`Snapshot::languages`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LanguageSnapshot { + /// Analysis levels for this language, keyed by level name (e.g. `"files"`, + /// `"functions"`). + pub graphs: BTreeMap, + /// Prompt-Generator principles (refactoring principles), language-adapted. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub principles: Vec, + /// Prompt-Generator scaffolding prose (language-neutral framing), so the CLI + /// `prompt` format and the HTML viewer render the same text from one source. + #[serde(default)] + pub prompt: code_ranker_plugin_api::PromptTemplate, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Snapshot { pub schema_version: String, @@ -36,7 +54,8 @@ pub struct Snapshot { pub workspace: String, /// The analyzed project directory (absolute path, stored once here). pub target: String, - pub plugin: String, + /// Sorted list of active plugin names for this analysis run. + pub plugins: Vec, /// Config file used for this analysis, if any was found. #[serde(default, skip_serializing_if = "Option::is_none")] pub config_file: Option, @@ -48,15 +67,8 @@ pub struct Snapshot { pub git: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub timings: Vec, - /// Analysis levels, keyed by level name. Today only `"files"` is produced. - pub graphs: BTreeMap, - /// Prompt-Generator principles (refactoring principles), language-adapted. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub principles: Vec, - /// Prompt-Generator scaffolding prose (language-neutral framing), so the CLI - /// `prompt` format and the HTML viewer render the same text from one source. - #[serde(default)] - pub prompt: code_ranker_plugin_api::PromptTemplate, + /// Per-language analysis results, keyed by plugin name. + pub languages: BTreeMap, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -69,37 +81,38 @@ pub struct GitInfo { pub origin: Option, } +/// Named-field constructor for [`Snapshot`], replacing the old 12-argument +/// positional function. All callers should fill every field explicitly; use +/// `Default::default()` for truly optional ones. +pub struct SnapshotInit { + pub command: String, + pub workspace: String, + pub target: String, + /// Sorted list of active plugin names. + pub plugins: Vec, + pub config_file: Option, + pub versions: BTreeMap, + pub roots: BTreeMap, + pub git: Option, + pub timings: Vec, + pub languages: BTreeMap, +} + impl Snapshot { - #[allow(clippy::too_many_arguments)] - pub fn new( - command: String, - workspace: String, - target: String, - plugin: String, - config_file: Option, - versions: BTreeMap, - roots: BTreeMap, - git: Option, - timings: Vec, - graphs: BTreeMap, - principles: Vec, - prompt: code_ranker_plugin_api::PromptTemplate, - ) -> Self { + pub fn new(init: SnapshotInit) -> Self { Self { schema_version: SCHEMA_VERSION.to_string(), generated_at: Utc::now(), - command, - workspace, - target, - plugin, - config_file, - versions, - roots, - git, - timings, - graphs, - principles, - prompt, + command: init.command, + workspace: init.workspace, + target: init.target, + plugins: init.plugins, + config_file: init.config_file, + versions: init.versions, + roots: init.roots, + git: init.git, + timings: init.timings, + languages: init.languages, } } } diff --git a/crates/code-ranker-graph/src/version.rs b/crates/code-ranker-graph/src/version.rs index 0563ccc5..bd025f55 100644 --- a/crates/code-ranker-graph/src/version.rs +++ b/crates/code-ranker-graph/src/version.rs @@ -19,7 +19,7 @@ /// for an additive/back-compatible change (new optional key or flag), a **major** /// for a breaking one (renamed/removed key, flag or section). Set it to the app /// `major.minor` of the release that ships the change. -pub const CONFIG_VERSION: &str = "4.0"; +pub const CONFIG_VERSION: &str = "5.0"; /// The **JSON snapshot + viewer** format version. Written as the snapshot's /// `schema_version`, rejected on mismatch when a snapshot is read back @@ -29,4 +29,4 @@ pub const CONFIG_VERSION: &str = "4.0"; /// **Bump when** the snapshot JSON shape changes (a field added/renamed/removed, /// or the viewer's read contract changes) — same minor/major rule as /// [`CONFIG_VERSION`], set to the app `major.minor` of the shipping release. -pub const SCHEMA_VERSION: &str = "4.0"; +pub const SCHEMA_VERSION: &str = "5.0"; diff --git a/crates/code-ranker-plugin-api/src/plugin.rs b/crates/code-ranker-plugin-api/src/plugin.rs index f30c719d..400dd6a8 100644 --- a/crates/code-ranker-plugin-api/src/plugin.rs +++ b/crates/code-ranker-plugin-api/src/plugin.rs @@ -47,7 +47,7 @@ pub struct PluginInput { } pub trait LanguagePlugin: Sync { - /// Canonical name, e.g. `"rust"`. Used by `--plugin` and recorded in the + /// Canonical name, e.g. `"rust"`. Used by `--plugins` and recorded in the /// snapshot. Each plugin has exactly one name (js and ts are separate). fn name(&self) -> &str; @@ -59,18 +59,19 @@ pub trait LanguagePlugin: Sync { toml::Table::new() } - /// Can this plugin parse `workspace` (honoring `input`)? - fn detect(&self, workspace: &Path, input: &PluginInput) -> bool; + /// Can this plugin parse `workspace` (honoring `input` and the effective + /// language config `cfg`)? + fn detect(&self, cfg: &toml::Table, workspace: &Path, input: &PluginInput) -> bool; - /// Levels this plugin can produce, each carrying its edge-kind / attribute / - /// node-kind / cycle-kind semantics. - fn levels(&self) -> Vec; + /// Levels this plugin can produce under `cfg`, each carrying its edge-kind / + /// attribute / node-kind / cycle-kind semantics. + fn levels(&self, cfg: &toml::Table) -> Vec; /// Parse the workspace into the file-level graph. **Structure only**: nodes /// (with their structural attributes) + edges. Metrics are added downstream. /// When `input.ignore_tests` is set, the plugin must drop its own test files /// here (it knows the language's conventions). - fn analyze(&self, workspace: &Path, input: &PluginInput) -> Result; + fn analyze(&self, cfg: &toml::Table, workspace: &Path, input: &PluginInput) -> Result; /// **Measure** this language's per-file complexity tier-1 counts and return /// them keyed by `file` node id (an absolute path). The plugin parses each of @@ -79,7 +80,7 @@ pub trait LanguagePlugin: Sync { /// runs the tier-2 registry and writes every metric onto the node — so the /// plugin needs no dependency on the graph/enrichment crate. Default: none (a /// plugin that ships no metric engine). - fn metrics(&self, _graph: &Graph) -> Vec<(String, MetricInputs)> { + fn metrics(&self, _cfg: &toml::Table, _graph: &Graph) -> Vec<(String, MetricInputs)> { Vec::new() } @@ -91,12 +92,17 @@ pub trait LanguagePlugin: Sync { /// is the just-parsed file graph with **absolute** file-path ids, so a plugin /// reads each file by `node.id`. Only called when the level is enabled; /// default: none (a plugin that ships no function-level support). - fn function_units(&self, _graph: &Graph) -> Vec<(Node, MetricInputs)> { + fn function_units(&self, _cfg: &toml::Table, _graph: &Graph) -> Vec<(Node, MetricInputs)> { Vec::new() } /// Toolchain versions to record in the snapshot, e.g. `[("rustc", "1.88.0")]`. - fn versions(&self, _workspace: &Path, _input: &PluginInput) -> Vec<(String, String)> { + fn versions( + &self, + _cfg: &toml::Table, + _workspace: &Path, + _input: &PluginInput, + ) -> Vec<(String, String)> { Vec::new() } @@ -110,15 +116,15 @@ pub trait LanguagePlugin: Sync { /// /// This keeps language/toolchain knowledge inside the plugin instead of the /// language-agnostic orchestrator (mirrors [`versions`](Self::versions)). - fn roots(&self, _workspace: &Path) -> Vec<(String, String)> { + fn roots(&self, _cfg: &toml::Table, _workspace: &Path) -> Vec<(String, String)> { Vec::new() } /// The Prompt-Generator principles for this language. A plugin builds them from - /// its own config (the common catalog in `defaults.toml` merged with the - /// language's `.toml`, with each `doc_url` resolved). Default: none (a - /// plugin that ships no principles). - fn principles(&self, _input: &PluginInput) -> Vec { + /// the passed effective config `cfg` (the common catalog in `defaults.toml` + /// merged with any user overrides), with each `doc_url` resolved. + /// Default: none (a plugin that ships no principles). + fn principles(&self, _cfg: &toml::Table, _input: &PluginInput) -> Vec { Vec::new() } @@ -130,6 +136,7 @@ pub trait LanguagePlugin: Sync { /// the shared catalog stays neutral and each language refines only what differs. fn metric_specs( &self, + _cfg: &toml::Table, defaults: BTreeMap, ) -> BTreeMap { defaults @@ -141,7 +148,7 @@ pub trait LanguagePlugin: Sync { /// drops some, or reorders, via its `.toml` `[report]` section. The /// orchestrator applies the patch over the catalog defaults, then prunes to /// keys present. Default: no override (use the catalog lists as-is). - fn report_overrides(&self) -> ReportOverride { + fn report_overrides(&self, _cfg: &toml::Table) -> ReportOverride { ReportOverride::default() } } @@ -157,9 +164,10 @@ inventory::collect!(PluginRegistration); /// Every self-registered language plugin. The CLI works only through this array /// and the [`LanguagePlugin`] trait — it never names a concrete language. /// -/// Order is link order and is NOT significant: auto-detection treats multiple -/// matches as an error (it never picks by position), and any user-facing listing -/// sorts by [`LanguagePlugin::name`]. +/// Order is link order and is NOT significant: auto-detection considers all +/// plugins whose `detect()` returns true (multiple matches are normal for +/// multi-language repositories), and any user-facing listing sorts by +/// [`LanguagePlugin::name`]. pub fn registry() -> Vec<&'static dyn LanguagePlugin> { inventory::iter::() .map(|entry| entry.0) @@ -179,13 +187,13 @@ mod tests { fn name(&self) -> &str { "dummy" } - fn detect(&self, _w: &Path, _i: &PluginInput) -> bool { + fn detect(&self, _cfg: &toml::Table, _w: &Path, _i: &PluginInput) -> bool { false } - fn levels(&self) -> Vec { + fn levels(&self, _cfg: &toml::Table) -> Vec { Vec::new() } - fn analyze(&self, _w: &Path, _i: &PluginInput) -> Result { + fn analyze(&self, _cfg: &toml::Table, _w: &Path, _i: &PluginInput) -> Result { Ok(Graph { nodes: Vec::new(), edges: Vec::new(), @@ -198,12 +206,13 @@ mod tests { let p = Dummy; let ws = Path::new("/tmp"); let input = PluginInput::default(); + let cfg = toml::Table::new(); // Exercise the required methods too, so the dummy carries no dead code. assert_eq!(p.name(), "dummy"); - assert!(!p.detect(ws, &input)); - assert!(p.levels().is_empty()); - let g = p.analyze(ws, &input).expect("dummy analyze ok"); + assert!(!p.detect(&cfg, ws, &input)); + assert!(p.levels(&cfg).is_empty()); + let g = p.analyze(&cfg, ws, &input).expect("dummy analyze ok"); assert!(g.nodes.is_empty() && g.edges.is_empty()); let empty_graph = Graph { @@ -211,23 +220,29 @@ mod tests { edges: Vec::new(), }; assert!( - p.function_units(&empty_graph).is_empty(), + p.function_units(&cfg, &empty_graph).is_empty(), "default: no function units" ); - assert!(p.metrics(&empty_graph).is_empty(), "default: no metrics"); - assert!(p.versions(ws, &input).is_empty(), "default: no versions"); - assert!(p.roots(ws).is_empty(), "default: no roots"); + assert!( + p.metrics(&cfg, &empty_graph).is_empty(), + "default: no metrics" + ); + assert!( + p.versions(&cfg, ws, &input).is_empty(), + "default: no versions" + ); + assert!(p.roots(&cfg, ws).is_empty(), "default: no roots"); // config defaults to an empty table (a stub with no config file). assert!(p.config().is_empty(), "default: empty config table"); // principles defaults to none; metric_specs defaults to pass-through. - assert!(p.principles(&input).is_empty()); + assert!(p.principles(&cfg, &input).is_empty()); let specs: BTreeMap = BTreeMap::new(); - assert!(p.metric_specs(specs).is_empty()); + assert!(p.metric_specs(&cfg, specs).is_empty()); // report_overrides defaults to a no-op (catalog lists kept as-is). - let ro = p.report_overrides(); + let ro = p.report_overrides(&cfg); assert!(ro.columns.is_noop() && ro.card.is_noop() && ro.stats.is_noop()); } } diff --git a/crates/code-ranker-plugins/src/defaults.toml b/crates/code-ranker-plugins/src/defaults.toml index 2c248ba8..2ee7f60c 100644 --- a/crates/code-ranker-plugins/src/defaults.toml +++ b/crates/code-ranker-plugins/src/defaults.toml @@ -33,7 +33,7 @@ # the shared fallback corpus a language inherits when it has no own doc, mirroring # how config inherits this `defaults.toml`. A language ships its own corpus by # setting `doc_lang` + `doc_overrides` (see `rust/config.toml`). -doc_base = "https://github.com/ffedoroff/code-ranker/blob/main/languages" +doc_base = "https://github.com/ffedoroff/code-ranker/blob/main/plugins" # ────────────────────────────────────────────────────────────────────────────── # Field-omission defaults for the metric engine's role config — the fallbacks a @@ -146,6 +146,15 @@ label = "External" # the end, matching the rendered output exactly). # ────────────────────────────────────────────────────────────────────────────── +# Halstead total-count specs (N₁ / N₂) are language-AGNOSTIC prose, so they +# live in the base; η₁ / η₂ list each language’s token set and stay per-language. +[specs.n1] +description = "Total operators (N₁): every operator occurrence counted with repetition (the η₁ tokens, not deduplicated)." + +[specs.n2] +description = "Total operands (N₂): every operand occurrence counted with repetition (the η₂ texts, not deduplicated)." + + [[principles]] id = "CPX" title = "CPX — Reduce Complexity" diff --git a/crates/code-ranker-plugins/src/languages/c/mod.rs b/crates/code-ranker-plugins/src/languages/c/mod.rs index 5c5fa47d..eaa9a6e2 100644 --- a/crates/code-ranker-plugins/src/languages/c/mod.rs +++ b/crates/code-ranker-plugins/src/languages/c/mod.rs @@ -29,7 +29,6 @@ static CONFIG: LazyLock = LazyLock::new(|| { include_str!("config.toml"), ]) }); -static CFG: LazyLock = LazyLock::new(|| cfamily::Cfg::from_config(&CONFIG)); // Self-register this plugin (collected by `code_ranker_plugin_api::registry`); no // central list anywhere names a language. @@ -49,16 +48,17 @@ impl LanguagePlugin for CPlugin { "c" } - fn detect(&self, workspace: &Path, input: &PluginInput) -> bool { - cfamily::detect(workspace, &CFG, &crate::walk::ignore_from(input)) + fn detect(&self, cfg: &toml::Table, workspace: &Path, input: &PluginInput) -> bool { + let c = cfamily::Cfg::from_config(cfg); + cfamily::detect(workspace, &c, &crate::walk::ignore_from(input)) } - fn levels(&self) -> Vec { + fn levels(&self, cfg: &toml::Table) -> Vec { vec![ Level { name: "files".into(), - edge_kinds: crate::config::edge_kinds(&CONFIG), - node_attributes: crate::config::node_attributes(&CONFIG), + edge_kinds: crate::config::edge_kinds(cfg), + node_attributes: crate::config::node_attributes(cfg), edge_attributes: BTreeMap::new(), attribute_groups: BTreeMap::new(), node_kinds: default_node_kinds(), @@ -71,43 +71,48 @@ impl LanguagePlugin for CPlugin { node_attributes: BTreeMap::new(), edge_attributes: BTreeMap::new(), attribute_groups: BTreeMap::new(), - node_kinds: crate::config::node_kinds(&CONFIG), + node_kinds: crate::config::node_kinds(cfg), cycle_kinds: default_cycle_kinds(), grouping: None, }, ] } - fn analyze(&self, workspace: &Path, input: &PluginInput) -> Result { + fn analyze(&self, cfg: &toml::Table, workspace: &Path, input: &PluginInput) -> Result { + let c = cfamily::Cfg::from_config(cfg); cfamily::analyze( workspace, input.ignore_tests, - &CFG, + &c, &crate::walk::ignore_from(input), ) } - fn metrics(&self, graph: &Graph) -> Vec<(String, MetricInputs)> { + fn metrics(&self, _cfg: &toml::Table, graph: &Graph) -> Vec<(String, MetricInputs)> { file_metrics(graph) } - fn function_units(&self, graph: &Graph) -> Vec<(Node, MetricInputs)> { + fn function_units(&self, _cfg: &toml::Table, graph: &Graph) -> Vec<(Node, MetricInputs)> { function_nodes(graph) } - fn principles(&self, _input: &PluginInput) -> Vec { - crate::config::resolved_principles(&CONFIG) + fn principles(&self, cfg: &toml::Table, _input: &PluginInput) -> Vec { + crate::config::resolved_principles(cfg) } - fn report_overrides(&self) -> code_ranker_plugin_api::report::ReportOverride { - code_ranker_plugin_api::list_override::report_override(&CONFIG) + fn report_overrides( + &self, + cfg: &toml::Table, + ) -> code_ranker_plugin_api::report::ReportOverride { + code_ranker_plugin_api::list_override::report_override(cfg) } fn metric_specs( &self, + cfg: &toml::Table, defaults: BTreeMap, ) -> BTreeMap { - crate::config::apply_spec_overrides(defaults, &CONFIG) + crate::config::apply_spec_overrides(defaults, cfg) } } diff --git a/crates/code-ranker-plugins/src/languages/c/tests/mod_rs.rs b/crates/code-ranker-plugins/src/languages/c/tests/mod_rs.rs index b0295c74..272cb5f1 100644 --- a/crates/code-ranker-plugins/src/languages/c/tests/mod_rs.rs +++ b/crates/code-ranker-plugins/src/languages/c/tests/mod_rs.rs @@ -6,9 +6,10 @@ use super::*; fn detects_by_c_source_presence() { let d = tempfile::tempdir().unwrap(); let p = CPlugin; - assert!(!p.detect(d.path(), &PluginInput::default())); + let cfg = p.config(); + assert!(!p.detect(&cfg, d.path(), &PluginInput::default())); std::fs::write(d.path().join("main.c"), "int main(){return 0;}\n").unwrap(); - assert!(p.detect(d.path(), &PluginInput::default())); + assert!(p.detect(&cfg, d.path(), &PluginInput::default())); } #[test] @@ -20,9 +21,14 @@ fn metrics_and_function_units_over_a_temp_project() { ) .unwrap(); let p = CPlugin; - let g = p.analyze(d.path(), &PluginInput::default()).unwrap(); - assert!(!p.metrics(&g).is_empty(), "file metrics produced"); - assert!(p.function_units(&g).iter().any(|(n, _)| n.name == "add")); + let cfg = p.config(); + let g = p.analyze(&cfg, d.path(), &PluginInput::default()).unwrap(); + assert!(!p.metrics(&cfg, &g).is_empty(), "file metrics produced"); + assert!( + p.function_units(&cfg, &g) + .iter() + .any(|(n, _)| n.name == "add") + ); assert_eq!(p.name(), "c"); } @@ -45,6 +51,7 @@ fn metrics_skip_non_file_and_unreadable_nodes() { ], edges: vec![], }; - assert!(CPlugin.metrics(&g).is_empty()); - assert!(CPlugin.function_units(&g).is_empty()); + let cfg = CPlugin.config(); + assert!(CPlugin.metrics(&cfg, &g).is_empty()); + assert!(CPlugin.function_units(&cfg, &g).is_empty()); } diff --git a/crates/code-ranker-plugins/src/languages/c/tests/sample/code-ranker-check.sarif b/crates/code-ranker-plugins/src/languages/c/tests/sample/code-ranker-check.sarif index 03beb451..b2abc2cf 100644 --- a/crates/code-ranker-plugins/src/languages/c/tests/sample/code-ranker-check.sarif +++ b/crates/code-ranker-plugins/src/languages/c/tests/sample/code-ranker-check.sarif @@ -8,7 +8,7 @@ "informationUri": "https://github.com/ffedoroff/code-ranker", "name": "code-ranker", "rules": [], - "version": "3.0.0-alpha.1" + "version": "4.0.0" } } } diff --git a/crates/code-ranker-plugins/src/languages/c/tests/sample/code-ranker-report.json b/crates/code-ranker-plugins/src/languages/c/tests/sample/code-ranker-report.json index 6c3aa3bc..e06f4b03 100644 --- a/crates/code-ranker-plugins/src/languages/c/tests/sample/code-ranker-report.json +++ b/crates/code-ranker-plugins/src/languages/c/tests/sample/code-ranker-report.json @@ -1,5 +1,5 @@ { - "command": "code-ranker report crates/code-ranker-plugins/src/languages/c/tests/sample --config crates/code-ranker-plugins/src/languages/c/tests/sample/code-ranker.toml --output.json.path=crates/code-ranker-plugins/src/languages/c/tests/sample/code-ranker-report.json --output.mode quiet", + "command": "code-ranker report crates/code-ranker-plugins/src/languages/c/tests/sample --config crates/code-ranker-plugins/src/languages/c/tests/sample/code-ranker.toml --output.json.path=crates/code-ranker-plugins/src/languages/c/tests/sample/code-ranker-report.json", "config_file": "crates/code-ranker-plugins/src/languages/c/tests/sample/code-ranker.toml", "generated_at": "1970-01-01T00:00:00Z", "git": { @@ -8,765 +8,771 @@ "dirty_files": 0, "origin": "git@example.com:org/repo.git" }, - "graphs": { - "files": { - "attribute_groups": { - "complexity": { - "description": "per-function branching, nesting & size", - "label": "Complexity" - }, - "coupling": { - "description": "how tightly modules depend on each other", - "label": "Coupling" - }, - "halstead": { - "description": "operator/operand vocabulary & derived effort", - "label": "Halstead" - }, - "loc": { - "description": "physical line counts", - "label": "Lines of Code" - }, - "maintainability": { - "description": "composite score", - "label": "Maintainability" - } - }, - "cycle_kinds": {}, - "cycles": [], - "edge_attributes": {}, - "edge_kinds": { - "uses": { - "description": "Import dependency — this file imports from the other.", - "flow": true, - "label": "uses" + "languages": { + "c": { + "graphs": { + "files": { + "attribute_groups": { + "complexity": { + "description": "per-function branching, nesting & size", + "label": "Complexity" + }, + "coupling": { + "description": "how tightly modules depend on each other", + "label": "Coupling" + }, + "halstead": { + "description": "operator/operand vocabulary & derived effort", + "label": "Halstead" + }, + "loc": { + "description": "physical line counts", + "label": "Lines of Code" + }, + "maintainability": { + "description": "composite score", + "label": "Maintainability" + } + }, + "cycle_kinds": {}, + "cycles": [], + "edge_attributes": {}, + "edge_kinds": { + "uses": { + "description": "Import dependency — this file imports from the other.", + "flow": true, + "label": "uses" + } + }, + "edges": [ + { + "kind": "uses", + "line": 1, + "source": "{target}/main.c", + "target": "ext:stdio.h" + }, + { + "kind": "uses", + "line": 2, + "source": "{target}/main.c", + "target": "{target}/mathx.h" + }, + { + "kind": "uses", + "line": 1, + "source": "{target}/mathx.c", + "target": "{target}/mathx.h" + }, + { + "kind": "uses", + "line": 5, + "source": "{target}/mathx.h", + "target": "{target}/util.h" + }, + { + "kind": "uses", + "line": 1, + "source": "{target}/util.c", + "target": "{target}/util.h" + } + ], + "node_attributes": { + "args": { + "description": "Number of function / closure arguments.", + "direction": "lower_better", + "group": "complexity", + "label": "Args", + "name": "Arguments", + "short": "Args", + "value_type": "int" + }, + "blank": { + "description": "Empty or whitespace-only lines.", + "group": "loc", + "label": "Blank", + "name": "Blank lines", + "short": "Blank", + "value_type": "int" + }, + "branches": { + "description": "Decision points: if / for / while / loop / match arm / try / && / ||. Feeds `cyclomatic`.", + "direction": "lower_better", + "group": "complexity", + "label": "Branches", + "name": "Decision points", + "short": "Branches", + "value_type": "int" + }, + "bugs": { + "calc": "effort ** (2/3) / 3000", + "description": "Estimated delivered bugs — a rough predictor of defect density.", + "direction": "lower_better", + "formula": "effort^⅔ ÷ 3000", + "group": "halstead", + "label": "Bugs", + "name": "Estimated bugs", + "short": "H.bugs", + "value_type": "float" + }, + "cloc": { + "description": "Comment-only lines (inline comments on code lines are not counted).", + "group": "loc", + "label": "Comments", + "name": "Comment lines", + "short": "Comments", + "value_type": "int" + }, + "cognitive": { + "description": "How hard the code is for a human to follow — not just how many paths it has.
Like `cyclomatic` it adds +1 for each break in linear flow (`if`, `else`, `match`, loops, `catch`, chained `&&` / `||`), but it also adds an extra +1 for every level of nesting: an `if` inside a loop inside an `if` costs far more than three flat `if`s.
That nesting penalty is the point — deeply indented logic is what actually strains a reader, so a high `cognitive` next to a modest `cyclomatic` flags tangled, hard-to-read code.
Summed across every function in the file.", + "direction": "lower_better", + "group": "complexity", + "label": "Cognitive", + "name": "Cognitive complexity", + "short": "Cognitive", + "value_type": "int" + }, + "cyclomatic": { + "calc": "spaces + branches", + "description": "Number of independent paths through the code — roughly the minimum number of test cases needed to cover every branch.
A function starts at 1 and gains +1 per decision point: each `if` / `else if`, every `match` / `switch` arm, every loop, and each `&&` / `||` in a condition.
Summed across every function in the file, so it grows with both size and branching — the file's total branching burden.
Counts paths only, ignoring how deeply they nest. For a readability-weighted view see `cognitive`.", + "direction": "lower_better", + "formula": "spaces + branches", + "group": "complexity", + "label": "Cyclomatic", + "name": "Cyclomatic complexity", + "omit_at": 1.0, + "short": "Cyclomatic", + "value_type": "int" + }, + "effort": { + "calc": "(eta1 / 2) * (n2 / eta2) * volume", + "description": "Mental effort to implement the algorithm.", + "direction": "lower_better", + "formula": "(eta1 ÷ 2) × (n2 ÷ eta2) × volume", + "group": "halstead", + "label": "Effort", + "name": "Implementation effort", + "short": "H.effort", + "value_type": "float" + }, + "eta1": { + "description": "Distinct operators (η₁): the count of unique operator token kinds. C counts punctuation & delimiters (`( { [ , . ; : ->`), arithmetic / bitwise / comparison / assignment operators (`+ - * / % ++ -- == != < > <= >= && || ! & | ^ << >> = += … ?: ~`), the keywords `break case const continue default do else enum extern for goto if inline return sizeof static struct switch typedef union volatile while auto register signed unsigned short long`, and the preprocessor directives `#define #include #if #ifdef #ifndef #else #endif #elif`.", + "direction": "lower_better", + "group": "halstead", + "label": "η₁", + "name": "Unique operators", + "short": "η₁", + "value_type": "int" + }, + "eta2": { + "description": "Distinct operands (η₂): the count of unique operand texts. C counts identifiers (incl. field / type / label identifiers), literals (number, char, string, `` lib strings), primitive type names, and `true` / `false` / `NULL`.", + "direction": "lower_better", + "group": "halstead", + "label": "η₂", + "name": "Unique operands", + "short": "η₂", + "value_type": "int" + }, + "exits": { + "description": "Number of exit points (return/throw) in the unit.", + "direction": "lower_better", + "group": "complexity", + "label": "Exits", + "name": "Exit points", + "short": "Exits", + "value_type": "int" + }, + "external": { + "label": "External", + "value_type": "bool" + }, + "fan_in": { + "description": "Many other units depend on this one, making it risky to change and a single point of failure — though some hubs (shared types) carry high fan-in legitimately.", + "group": "coupling", + "label": "Fan-in", + "name": "Incoming dependencies", + "short": "Fan-in", + "value_type": "int" + }, + "fan_out": { + "description": "This unit depends on many others, so it breaks when any of them change and is hard to test in isolation.", + "group": "coupling", + "label": "Fan-out", + "name": "Outgoing dependencies", + "short": "Fan-out", + "value_type": "int" + }, + "fan_out_external": { + "description": "Number of distinct external libraries this node depends on.", + "group": "coupling", + "label": "Fan-out (external)", + "name": "External dependencies", + "short": "Fan-out (external)", + "value_type": "int" + }, + "hk": { + "abbreviate": true, + "calc": "sloc * (fan_in * fan_out) ** 2", + "description": "Henry-Kafura information-flow complexity: a module that is both a busy crossroads (high fan-in × fan-out) and large — the most expensive place in the codebase to change.", + "direction": "lower_better", + "formula": "sloc × (fan_in × fan_out)²", + "group": "coupling", + "label": "HK", + "name": "God-object risk", + "short": "HK", + "value_type": "float" + }, + "length": { + "calc": "n1 + n2", + "description": "Program length — total operator + operand occurrences.", + "direction": "lower_better", + "formula": "n1 + n2", + "group": "halstead", + "label": "Length", + "name": "Total tokens", + "short": "H.len", + "value_type": "float" + }, + "lloc": { + "description": "Logical lines — counts statements, not physical lines.", + "group": "loc", + "label": "Logical", + "name": "Logical lines", + "short": "Logical", + "value_type": "int" + }, + "loc": { + "description": "Raw file line count, including blank and comment lines (unlike `sloc`). Large files tend to hold several responsibilities and are harder to review, test, and reuse.", + "group": "loc", + "label": "Lines", + "name": "Total lines", + "remediation": "Split by responsibility into smaller units, extract helpers, and separate data definitions from behavior. For an average breach, break up the largest units first (--top).", + "value_type": "int" + }, + "mi": { + "calc": "171 - 5.2*Math.log(volume) - 0.23*cyclomatic - 16.2*Math.log(span_sloc)", + "description": "Maintainability Index (0–100, higher is more maintainable). Derived from Halstead volume, cyclomatic complexity, and SLOC.", + "direction": "higher_better", + "formula": "171 − 5.2·ln(volume) − 0.23·cyclomatic − 16.2·ln(span_sloc)", + "group": "maintainability", + "label": "MI", + "name": "Maintainability index", + "short": "MI", + "value_type": "float" + }, + "mi_sei": { + "calc": "171 - 5.2*Math.log2(volume) - 0.23*cyclomatic - 16.2*Math.log2(span_sloc) + 50*Math.sin(Math.sqrt(cloc / span_sloc * 2.4))", + "description": "SEI variant of the Maintainability Index — adds a bonus for comment density.", + "direction": "higher_better", + "formula": "171 − 5.2·log₂(volume) − 0.23·cyclomatic − 16.2·log₂(span_sloc) + 50·sin(√(cloc ÷ span_sloc × 2.4))", + "group": "maintainability", + "label": "MI (SEI)", + "name": "Maintainability (SEI)", + "short": "MI SEI", + "value_type": "float" + }, + "n1": { + "description": "Total operators (N₁): every operator occurrence counted with repetition (the η₁ tokens, not deduplicated).", + "direction": "lower_better", + "group": "halstead", + "label": "N₁", + "name": "Total operators", + "short": "N₁", + "value_type": "int" + }, + "n2": { + "description": "Total operands (N₂): every operand occurrence counted with repetition (the η₂ texts, not deduplicated).", + "direction": "lower_better", + "group": "halstead", + "label": "N₂", + "name": "Total operands", + "short": "N₂", + "value_type": "int" + }, + "sloc": { + "description": "Source lines of code — lines with at least one non-whitespace, non-comment character. Blank and comment-only lines are not counted (unlike `loc`, the raw file line count).", + "group": "loc", + "label": "Source", + "name": "Source lines", + "short": "SLOC", + "value_type": "int" + }, + "spaces": { + "description": "Unit count: the source file (1) plus each function / impl / trait / closure space. Feeds `cyclomatic`.", + "direction": "lower_better", + "group": "complexity", + "label": "Spaces", + "name": "Code units", + "short": "Spaces", + "value_type": "int" + }, + "span_sloc": { + "description": "Line span of the unit (end_row − start_row) — the size input the Maintainability Index (`mi` / `mi_sei`) is computed from.", + "direction": "lower_better", + "group": "maintainability", + "label": "Span", + "name": "Line span", + "short": "Span", + "value_type": "int" + }, + "time": { + "calc": "effort / 18", + "description": "Estimated implementation time, in seconds.", + "direction": "lower_better", + "formula": "effort ÷ 18", + "group": "halstead", + "label": "Time", + "name": "Coding time (s)", + "short": "H.time(s)", + "value_type": "float" + }, + "vocabulary": { + "calc": "eta1 + eta2", + "description": "Vocabulary — distinct operators + operands.", + "direction": "lower_better", + "formula": "eta1 + eta2", + "group": "halstead", + "label": "Vocabulary", + "name": "Distinct symbols", + "short": "H.vocab", + "value_type": "float" + }, + "volume": { + "calc": "length * Math.log2(vocabulary)", + "description": "Algorithm size in bits, from distinct operators and operands.", + "direction": "lower_better", + "formula": "length × log₂(vocabulary)", + "group": "halstead", + "label": "Volume", + "name": "Code volume", + "short": "H.vol", + "value_type": "float" + } + }, + "node_kinds": { + "external": { + "external": true, + "fill": "#f6e2c0", + "label": "Library", + "plural": "Libraries", + "stroke": "#b3801f" + }, + "file": { + "fill": "#dbe9f4", + "label": "File", + "plural": "Files", + "stroke": "#4d6f9c" + } + }, + "nodes": [ + { + "external": true, + "id": "ext:stdio.h", + "kind": "external", + "name": "stdio.h" + }, + { + "args": 1, + "blank": 1, + "branches": 3, + "bugs": 0.0841, + "cloc": 2, + "cognitive": 4, + "cyclomatic": 5, + "effort": 4009.63, + "eta1": 16, + "eta2": 14, + "exits": 1, + "fan_out": 1, + "fan_out_external": 1, + "id": "{target}/main.c", + "kind": "file", + "length": 55, + "lloc": 9, + "loc": 15, + "mi": 96.87, + "mi_sei": 91.362, + "n1": 29, + "n2": 26, + "name": "main.c", + "sloc": 12, + "spaces": 2, + "span_sloc": 15, + "time": 222.757, + "vocabulary": 30, + "volume": 269.878 + }, + { + "args": 1, + "blank": 1, + "bugs": 0.0234, + "cloc": 1, + "cyclomatic": 2, + "effort": 589.382, + "eta1": 8, + "eta2": 7, + "exits": 1, + "fan_out": 1, + "id": "{target}/mathx.c", + "kind": "file", + "length": 22, + "lloc": 3, + "loc": 7, + "mi": 115.856, + "mi_sei": 119.28, + "n1": 10, + "n2": 12, + "name": "mathx.c", + "sloc": 5, + "spaces": 2, + "span_sloc": 7, + "time": 32.743, + "vocabulary": 15, + "volume": 85.951 + }, + { + "blank": 3, + "bugs": 0.0109, + "cloc": 1, + "effort": 188.884, + "eta1": 6, + "eta2": 5, + "fan_in": 2, + "fan_out": 1, + "hk": 20, + "id": "{target}/mathx.h", + "kind": "file", + "length": 13, + "lloc": 3, + "loc": 9, + "mi": 115.383, + "mi_sei": 115.551, + "n1": 6, + "n2": 7, + "name": "mathx.h", + "sloc": 5, + "spaces": 1, + "span_sloc": 9, + "time": 10.493, + "vocabulary": 11, + "volume": 44.972 + }, + { + "args": 2, + "blank": 1, + "branches": 1, + "bugs": 0.0274, + "cloc": 1, + "cognitive": 1, + "cyclomatic": 3, + "effort": 748.968, + "eta1": 8, + "eta2": 5, + "exits": 2, + "fan_out": 1, + "id": "{target}/util.c", + "kind": "file", + "length": 23, + "lloc": 4, + "loc": 9, + "mi": 111.606, + "mi_sei": 110.306, + "n1": 12, + "n2": 11, + "name": "util.c", + "sloc": 7, + "spaces": 2, + "span_sloc": 9, + "time": 41.609, + "vocabulary": 13, + "volume": 85.11 + }, + { + "blank": 2, + "bugs": 0.0126, + "cloc": 1, + "effort": 232.473, + "eta1": 6, + "eta2": 5, + "fan_in": 2, + "id": "{target}/util.h", + "kind": "file", + "length": 14, + "lloc": 2, + "loc": 7, + "mi": 119.069, + "mi_sei": 123.814, + "n1": 6, + "n2": 8, + "name": "util.h", + "sloc": 4, + "spaces": 1, + "span_sloc": 7, + "time": 12.915, + "vocabulary": 11, + "volume": 48.432 + } + ], + "stats": { + "blank": 1.6, + "bugs": 0.0316, + "cloc": 1.2, + "cognitive": 2.5, + "cyclomatic": 3.333, + "effort": 1153.867, + "fan_in": 2, + "fan_out": 1, + "hk": 20, + "length": 25.4, + "mi": 111.756, + "mi_sei": 112.062, + "sloc": 6.6, + "time": 64.103, + "vocabulary": 16, + "volume": 106.868 + }, + "ui": { + "card": [ + "hk", + "sloc" + ], + "columns": [ + "kind", + "sloc", + "hk", + "fan_in", + "fan_out", + "volume", + "bugs", + "effort", + "time", + "length", + "vocabulary", + "cyclomatic", + "cognitive", + "mi", + "mi_sei", + "lloc", + "cloc", + "blank" + ], + "default_sort": "hk", + "filter": [], + "size": [ + "sloc", + "hk" + ], + "sort": [ + "sloc", + "hk", + "fan_in", + "fan_out", + "volume", + "bugs", + "effort", + "time", + "length", + "vocabulary", + "cyclomatic", + "cognitive", + "mi", + "mi_sei", + "lloc", + "cloc", + "blank" + ], + "summary": [ + "sloc", + "hk", + "fan_in", + "fan_out", + "volume", + "bugs", + "effort", + "time", + "length", + "vocabulary", + "cyclomatic", + "cognitive", + "mi", + "mi_sei", + "lloc", + "cloc", + "blank" + ] + } } }, - "edges": [ + "principles": [ { - "kind": "uses", - "line": 1, - "source": "{target}/main.c", - "target": "ext:stdio.h" + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/CPX.md", + "id": "CPX", + "label": "CPX", + "prompt": "These modules are too complex and I want to reduce their complexity.\nReduce it by splitting large units into smaller single-responsibility ones,\nextracting repeated patterns into shared helpers, flattening deeply nested\ncontrol flow, and breaking large functions into focused helpers.", + "sort_metric": "cognitive", + "title": "CPX — Reduce Complexity" }, { - "kind": "uses", - "line": 2, - "source": "{target}/main.c", - "target": "{target}/mathx.h" + "connections": [ + "common" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/ADP.md", + "id": "ADP", + "label": "ADP", + "prompt": "The dependency graph between modules must form a DAG. When module A depends\non module B, no chain of dependencies should bring B back to A.\n\nIdentify any cycles in the modules below. For each cycle, propose a concrete\nrefactoring (extract a shared abstraction, invert a dependency, split a module)\nthat makes the graph acyclic without breaking existing functionality.\n\nWhen splitting a module to break a cycle, the new structure should:\n- Preserve existing API contracts\n- Minimise coupling in the new structure\n- Follow the Single Responsibility Principle\n- Not introduce new dependency cycles", + "sort_metric": "cycle", + "title": "ADP — Acyclic Dependencies Principle" }, { - "kind": "uses", - "line": 1, - "source": "{target}/mathx.c", - "target": "{target}/mathx.h" + "connections": [ + "in", + "out" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/SRP.md", + "id": "SRP", + "label": "SRP", + "prompt": "A module should have one reason to change — it should serve one actor\nand encapsulate one coherent set of decisions.\n\nFor each module below, identify whether it has more than one responsibility.\nPropose how to split responsibilities so each module changes for only one reason,\nand specify the new module boundaries.", + "sort_metric": "sloc", + "title": "SRP — Single Responsibility Principle" }, { - "kind": "uses", - "line": 5, - "source": "{target}/mathx.h", - "target": "{target}/util.h" + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/OCP.md", + "id": "OCP", + "label": "OCP", + "prompt": "A module should be open for extension but closed for modification: new behaviour\nshould be addable without editing existing, working code.\n\nFor each module below, identify extension points that currently require editing\nexisting code (e.g. growing match/switch/if-else chains). Propose an extension\nmechanism (polymorphism, strategy, plug-in registration) so new cases can be added\nwithout modifying these modules.", + "sort_metric": "cyclomatic", + "title": "OCP — Open/Closed Principle" }, { - "kind": "uses", - "line": 1, - "source": "{target}/util.c", - "target": "{target}/util.h" - } - ], - "node_attributes": { - "args": { - "description": "Number of function / closure arguments.", - "direction": "lower_better", - "group": "complexity", - "label": "Args", - "name": "Arguments", - "short": "Args", - "value_type": "int" - }, - "blank": { - "description": "Empty or whitespace-only lines.", - "group": "loc", - "label": "Blank", - "name": "Blank lines", - "short": "Blank", - "value_type": "int" - }, - "branches": { - "description": "Decision points: if / for / while / loop / match arm / try / && / ||. Feeds `cyclomatic`.", - "direction": "lower_better", - "group": "complexity", - "label": "Branches", - "name": "Decision points", - "short": "Branches", - "value_type": "int" - }, - "bugs": { - "calc": "effort ** (2/3) / 3000", - "description": "Estimated delivered bugs — a rough predictor of defect density.", - "direction": "lower_better", - "formula": "effort^⅔ ÷ 3000", - "group": "halstead", - "label": "Bugs", - "name": "Estimated bugs", - "short": "H.bugs", - "value_type": "float" - }, - "cloc": { - "description": "Comment-only lines (inline comments on code lines are not counted).", - "group": "loc", - "label": "Comments", - "name": "Comment lines", - "short": "Comments", - "value_type": "int" - }, - "cognitive": { - "description": "How hard the code is for a human to follow — not just how many paths it has.
Like `cyclomatic` it adds +1 for each break in linear flow (`if`, `else`, `match`, loops, `catch`, chained `&&` / `||`), but it also adds an extra +1 for every level of nesting: an `if` inside a loop inside an `if` costs far more than three flat `if`s.
That nesting penalty is the point — deeply indented logic is what actually strains a reader, so a high `cognitive` next to a modest `cyclomatic` flags tangled, hard-to-read code.
Summed across every function in the file.", - "direction": "lower_better", - "group": "complexity", - "label": "Cognitive", - "name": "Cognitive complexity", - "short": "Cognitive", - "value_type": "int" - }, - "cyclomatic": { - "calc": "spaces + branches", - "description": "Number of independent paths through the code — roughly the minimum number of test cases needed to cover every branch.
A function starts at 1 and gains +1 per decision point: each `if` / `else if`, every `match` / `switch` arm, every loop, and each `&&` / `||` in a condition.
Summed across every function in the file, so it grows with both size and branching — the file's total branching burden.
Counts paths only, ignoring how deeply they nest. For a readability-weighted view see `cognitive`.", - "direction": "lower_better", - "formula": "spaces + branches", - "group": "complexity", - "label": "Cyclomatic", - "name": "Cyclomatic complexity", - "omit_at": 1.0, - "short": "Cyclomatic", - "value_type": "int" - }, - "effort": { - "calc": "(eta1 / 2) * (n2 / eta2) * volume", - "description": "Mental effort to implement the algorithm.", - "direction": "lower_better", - "formula": "(eta1 ÷ 2) × (n2 ÷ eta2) × volume", - "group": "halstead", - "label": "Effort", - "name": "Implementation effort", - "short": "H.effort", - "value_type": "float" - }, - "eta1": { - "description": "Distinct operators (η₁): the count of unique operator token kinds. C counts punctuation & delimiters (`( { [ , . ; : ->`), arithmetic / bitwise / comparison / assignment operators (`+ - * / % ++ -- == != < > <= >= && || ! & | ^ << >> = += … ?: ~`), the keywords `break case const continue default do else enum extern for goto if inline return sizeof static struct switch typedef union volatile while auto register signed unsigned short long`, and the preprocessor directives `#define #include #if #ifdef #ifndef #else #endif #elif`.", - "direction": "lower_better", - "group": "halstead", - "label": "η₁", - "name": "Unique operators", - "short": "η₁", - "value_type": "int" - }, - "eta2": { - "description": "Distinct operands (η₂): the count of unique operand texts. C counts identifiers (incl. field / type / label identifiers), literals (number, char, string, `` lib strings), primitive type names, and `true` / `false` / `NULL`.", - "direction": "lower_better", - "group": "halstead", - "label": "η₂", - "name": "Unique operands", - "short": "η₂", - "value_type": "int" - }, - "exits": { - "description": "Number of exit points (return/throw) in the unit.", - "direction": "lower_better", - "group": "complexity", - "label": "Exits", - "name": "Exit points", - "short": "Exits", - "value_type": "int" - }, - "external": { - "label": "External", - "value_type": "bool" + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/LSP.md", + "id": "LSP", + "label": "LSP", + "prompt": "Every implementation of an interface must honour its full contract — return-value\ninvariants, error/exception behaviour, side effects, and resource ownership — not\njust the method signatures. A subtype must be substitutable for its base without\nsurprising callers.\n\nIdentify the interface implementations in the modules below. For each, check it can\nreplace any other implementation of the same interface without breaking callers.\nFlag violations and propose fixes.", + "sort_metric": "hk", + "title": "LSP — Liskov Substitution Principle" }, - "fan_in": { - "description": "Many other units depend on this one, making it risky to change and a single point of failure — though some hubs (shared types) carry high fan-in legitimately.", - "group": "coupling", - "label": "Fan-in", - "name": "Incoming dependencies", - "short": "Fan-in", - "value_type": "int" - }, - "fan_out": { - "description": "This unit depends on many others, so it breaks when any of them change and is hard to test in isolation.", - "group": "coupling", - "label": "Fan-out", - "name": "Outgoing dependencies", - "short": "Fan-out", - "value_type": "int" - }, - "fan_out_external": { - "description": "Number of distinct external libraries this node depends on.", - "group": "coupling", - "label": "Fan-out (external)", - "name": "External dependencies", - "short": "Fan-out (external)", - "value_type": "int" - }, - "hk": { - "abbreviate": true, - "calc": "sloc * (fan_in * fan_out) ** 2", - "description": "Henry-Kafura information-flow complexity: a module that is both a busy crossroads (high fan-in × fan-out) and large — the most expensive place in the codebase to change.", - "direction": "lower_better", - "formula": "sloc × (fan_in × fan_out)²", - "group": "coupling", - "label": "HK", - "name": "God-object risk", - "short": "HK", - "value_type": "float" - }, - "length": { - "calc": "n1 + n2", - "description": "Program length — total operator + operand occurrences.", - "direction": "lower_better", - "formula": "n1 + n2", - "group": "halstead", - "label": "Length", - "name": "Total tokens", - "short": "H.len", - "value_type": "float" - }, - "lloc": { - "description": "Logical lines — counts statements, not physical lines.", - "group": "loc", - "label": "Logical", - "name": "Logical lines", - "short": "Logical", - "value_type": "int" - }, - "loc": { - "description": "Raw file line count, including blank and comment lines (unlike `sloc`). Large files tend to hold several responsibilities and are harder to review, test, and reuse.", - "group": "loc", - "label": "Lines", - "name": "Total lines", - "remediation": "Split by responsibility into smaller units, extract helpers, and separate data definitions from behavior. For an average breach, break up the largest units first (--top).", - "value_type": "int" - }, - "mi": { - "calc": "171 - 5.2*Math.log(volume) - 0.23*cyclomatic - 16.2*Math.log(span_sloc)", - "description": "Maintainability Index (0–100, higher is more maintainable). Derived from Halstead volume, cyclomatic complexity, and SLOC.", - "direction": "higher_better", - "formula": "171 − 5.2·ln(volume) − 0.23·cyclomatic − 16.2·ln(span_sloc)", - "group": "maintainability", - "label": "MI", - "name": "Maintainability index", - "short": "MI", - "value_type": "float" - }, - "mi_sei": { - "calc": "171 - 5.2*Math.log2(volume) - 0.23*cyclomatic - 16.2*Math.log2(span_sloc) + 50*Math.sin(Math.sqrt(cloc / span_sloc * 2.4))", - "description": "SEI variant of the Maintainability Index — adds a bonus for comment density.", - "direction": "higher_better", - "formula": "171 − 5.2·log₂(volume) − 0.23·cyclomatic − 16.2·log₂(span_sloc) + 50·sin(√(cloc ÷ span_sloc × 2.4))", - "group": "maintainability", - "label": "MI (SEI)", - "name": "Maintainability (SEI)", - "short": "MI SEI", - "value_type": "float" - }, - "n1": { - "description": "Total operators (N₁): every operator occurrence counted with repetition (the η₁ tokens, not deduplicated).", - "direction": "lower_better", - "group": "halstead", - "label": "N₁", - "name": "Total operators", - "short": "N₁", - "value_type": "int" - }, - "n2": { - "description": "Total operands (N₂): every operand occurrence counted with repetition (the η₂ texts, not deduplicated).", - "direction": "lower_better", - "group": "halstead", - "label": "N₂", - "name": "Total operands", - "short": "N₂", - "value_type": "int" - }, - "sloc": { - "description": "Source lines of code — lines with at least one non-whitespace, non-comment character. Blank and comment-only lines are not counted (unlike `loc`, the raw file line count).", - "group": "loc", - "label": "Source", - "name": "Source lines", - "short": "SLOC", - "value_type": "int" - }, - "spaces": { - "description": "Unit count: the source file (1) plus each function / impl / trait / closure space. Feeds `cyclomatic`.", - "direction": "lower_better", - "group": "complexity", - "label": "Spaces", - "name": "Code units", - "short": "Spaces", - "value_type": "int" - }, - "span_sloc": { - "description": "Line span of the unit (end_row − start_row) — the size input the Maintainability Index (`mi` / `mi_sei`) is computed from.", - "direction": "lower_better", - "group": "maintainability", - "label": "Span", - "name": "Line span", - "short": "Span", - "value_type": "int" - }, - "time": { - "calc": "effort / 18", - "description": "Estimated implementation time, in seconds.", - "direction": "lower_better", - "formula": "effort ÷ 18", - "group": "halstead", - "label": "Time", - "name": "Coding time (s)", - "short": "H.time(s)", - "value_type": "float" - }, - "vocabulary": { - "calc": "eta1 + eta2", - "description": "Vocabulary — distinct operators + operands.", - "direction": "lower_better", - "formula": "eta1 + eta2", - "group": "halstead", - "label": "Vocabulary", - "name": "Distinct symbols", - "short": "H.vocab", - "value_type": "float" + { + "connections": [ + "in" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/ISP.md", + "id": "ISP", + "label": "ISP", + "prompt": "Clients should not be forced to depend on methods they do not use. Prefer several\nsmall, focused interfaces over one wide interface.\n\nIdentify interfaces in the modules below that are wider than their consumers need.\nPropose how to split them into narrower interfaces so each consumer depends only on\nwhat it actually uses.", + "sort_metric": "items", + "title": "ISP — Interface Segregation Principle" }, - "volume": { - "calc": "length * Math.log2(vocabulary)", - "description": "Algorithm size in bits, from distinct operators and operands.", - "direction": "lower_better", - "formula": "length × log₂(vocabulary)", - "group": "halstead", - "label": "Volume", - "name": "Code volume", - "short": "H.vol", - "value_type": "float" - } - }, - "node_kinds": { - "external": { - "external": true, - "fill": "#f6e2c0", - "label": "Library", - "plural": "Libraries", - "stroke": "#b3801f" + { + "connections": [ + "common", + "out" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/DIP.md", + "id": "DIP", + "label": "DIP", + "prompt": "High-level modules should not depend on low-level modules; both should depend on\nabstractions, and abstractions should not depend on details.\n\nFind places in the modules below where a high-level module depends directly on a\nconcrete low-level type. Propose an abstraction (interface) to invert each such\ndependency, and specify where the concrete implementation should be wired in.", + "sort_metric": "fan_out", + "title": "DIP — Dependency Inversion Principle" }, - "file": { - "fill": "#dbe9f4", - "label": "File", - "plural": "Files", - "stroke": "#4d6f9c" - } - }, - "nodes": [ { - "external": true, - "id": "ext:stdio.h", - "kind": "external", - "name": "stdio.h" + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/DRY.md", + "id": "DRY", + "label": "DRY", + "prompt": "Every piece of knowledge must have a single authoritative representation.\nDRY is about knowledge duplication, not just code duplication.\n\nIdentify concepts, rules, or policies that are duplicated across the modules\nbelow. For each duplication, propose a canonical location and the refactoring\nneeded to consolidate it.", + "sort_metric": "sloc", + "title": "DRY — Don't Repeat Yourself" }, { - "args": 1, - "blank": 1, - "branches": 3, - "bugs": 0.0841, - "cloc": 2, - "cognitive": 4, - "cyclomatic": 5, - "effort": 4009.63, - "eta1": 16, - "eta2": 14, - "exits": 1, - "fan_out": 1, - "fan_out_external": 1, - "id": "{target}/main.c", - "kind": "file", - "length": 55, - "lloc": 9, - "loc": 15, - "mi": 96.87, - "mi_sei": 91.362, - "n1": 29, - "n2": 26, - "name": "main.c", - "sloc": 12, - "spaces": 2, - "span_sloc": 15, - "time": 222.757, - "vocabulary": 30, - "volume": 269.878 + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/KISS.md", + "id": "KISS", + "label": "KISS", + "prompt": "When two designs solve the same problem, prefer the simpler one — fewer\nabstractions, fewer indirection layers, fewer moving parts.\n\nIdentify over-engineered or needlessly complex constructs in the modules below.\nFor each, describe the simpler alternative and estimate the risk of simplifying.", + "sort_metric": "cognitive", + "title": "KISS — Keep It Simple" }, { - "args": 1, - "blank": 1, - "bugs": 0.0234, - "cloc": 1, - "cyclomatic": 2, - "effort": 589.382, - "eta1": 8, - "eta2": 7, - "exits": 1, - "fan_out": 1, - "id": "{target}/mathx.c", - "kind": "file", - "length": 22, - "lloc": 3, - "loc": 7, - "mi": 115.856, - "mi_sei": 119.28, - "n1": 10, - "n2": 12, - "name": "mathx.c", - "sloc": 5, - "spaces": 2, - "span_sloc": 7, - "time": 32.743, - "vocabulary": 15, - "volume": 85.951 + "connections": [ + "common", + "out" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/LoD.md", + "id": "LoD", + "label": "LoD", + "prompt": "A method should only call methods on: itself, its direct fields,\nits parameters, and objects it constructs locally.\nAvoid `x.foo().bar().baz()` chains that traverse object graphs.\n\nIdentify method chains or deep field traversals in the modules below that\nviolate LoD. For each, propose a narrow accessor or a facade that exposes only\nwhat the caller needs, reducing coupling.", + "sort_metric": "fan_out", + "title": "Law of Demeter — Principle of Least Knowledge" }, { - "blank": 3, - "bugs": 0.0109, - "cloc": 1, - "effort": 188.884, - "eta1": 6, - "eta2": 5, - "fan_in": 2, - "fan_out": 1, - "hk": 20, - "id": "{target}/mathx.h", - "kind": "file", - "length": 13, - "lloc": 3, - "loc": 9, - "mi": 115.383, - "mi_sei": 115.551, - "n1": 6, - "n2": 7, - "name": "mathx.h", - "sloc": 5, - "spaces": 1, - "span_sloc": 9, - "time": 10.493, - "vocabulary": 11, - "volume": 44.972 + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/MISU.md", + "id": "MISU", + "label": "MISU", + "prompt": "Move correctness from runtime checks into the type system, so invalid states\ncannot be constructed and fail at compile time rather than at runtime.\n\nIdentify data structures or function signatures in the modules below where invalid\nstates are representable at runtime. For each, propose a type-level encoding\n(sum type / enum, newtype, typestate) that makes the invalid state unrepresentable\nby construction.", + "sort_metric": "cyclomatic", + "title": "MISU — Make Invalid States Unrepresentable" }, { - "args": 2, - "blank": 1, - "branches": 1, - "bugs": 0.0274, - "cloc": 1, - "cognitive": 1, - "cyclomatic": 3, - "effort": 748.968, - "eta1": 8, - "eta2": 5, - "exits": 2, - "fan_out": 1, - "id": "{target}/util.c", - "kind": "file", - "length": 23, - "lloc": 4, - "loc": 9, - "mi": 111.606, - "mi_sei": 110.306, - "n1": 12, - "n2": 11, - "name": "util.c", - "sloc": 7, - "spaces": 2, - "span_sloc": 9, - "time": 41.609, - "vocabulary": 13, - "volume": 85.11 + "connections": [ + "common" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/CoI.md", + "id": "CoI", + "label": "CoI", + "prompt": "Build behaviour by composing small, focused pieces rather than through deep\ninheritance hierarchies.\n\nIdentify large types that accumulate behaviour in the modules below. Propose how to\ndecompose them into smaller composable parts, and show how consumers would assemble\nthe behaviour they need.", + "sort_metric": "items", + "title": "CoI — Composition Over Inheritance" }, { - "blank": 2, - "bugs": 0.0126, - "cloc": 1, - "effort": 232.473, - "eta1": 6, - "eta2": 5, - "fan_in": 2, - "id": "{target}/util.h", - "kind": "file", - "length": 14, - "lloc": 2, - "loc": 7, - "mi": 119.069, - "mi_sei": 123.814, - "n1": 6, - "n2": 8, - "name": "util.h", - "sloc": 4, - "spaces": 1, - "span_sloc": 7, - "time": 12.915, - "vocabulary": 11, - "volume": 48.432 + "connections": [ + "out" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/YAGNI.md", + "id": "YAGNI", + "label": "YAGNI", + "prompt": "Build for the problem you have now, not one you imagine you might have later.\nDon't add an abstraction, a generic parameter, or a public API for a hypothetical\nfuture use.\n\nIdentify abstractions, generics, or public APIs in the modules below that were\nadded speculatively. For each, assess whether multiple real callers use it today,\nand propose simplification if not.", + "sort_metric": "sloc", + "title": "YAGNI — You Aren't Gonna Need It" } ], - "stats": { - "blank": 1.6, - "bugs": 0.0316, - "cloc": 1.2, - "cognitive": 2.5, - "cyclomatic": 3.333, - "effort": 1153.867, - "fan_in": 2, - "fan_out": 1, - "hk": 20, - "length": 25.4, - "mi": 111.756, - "mi_sei": 112.062, - "sloc": 6.6, - "time": 64.103, - "vocabulary": 16, - "volume": 106.868 - }, - "ui": { - "card": [ - "hk", - "sloc" - ], - "columns": [ - "kind", - "sloc", - "hk", - "fan_in", - "fan_out", - "volume", - "bugs", - "effort", - "time", - "length", - "vocabulary", - "cyclomatic", - "cognitive", - "mi", - "mi_sei", - "lloc", - "cloc", - "blank" - ], - "default_sort": "hk", - "filter": [], - "size": [ - "sloc", - "hk" - ], - "sort": [ - "sloc", - "hk", - "fan_in", - "fan_out", - "volume", - "bugs", - "effort", - "time", - "length", - "vocabulary", - "cyclomatic", - "cognitive", - "mi", - "mi_sei", - "lloc", - "cloc", - "blank" - ], - "summary": [ - "sloc", - "hk", - "fan_in", - "fan_out", - "volume", - "bugs", - "effort", - "time", - "length", - "vocabulary", - "cyclomatic", - "cognitive", - "mi", - "mi_sei", - "lloc", - "cloc", - "blank" + "prompt": { + "cycle_note": "This is **one** dependency cycle; every module in it is listed below so the whole loop is visible. Fix one cycle at a time — `--top 2`+ lists several separate cycles at once and obscures how each one connects.", + "doc_note": "**First, before reading the source**, run `code-ranker docs {id}` — it prints the full principle and, for your language, the usual cause of this exact violation and the smallest correct fix, often with a worked example and how to confirm it. Read it first: it normally names the remedy outright, so you apply it instead of re-deriving the mechanism from the code.", + "focus": "**Focus the research and report primarily on the modules below.**", + "intro": "I want to apply this to some modules in my system.", + "task": [ + "- Prepare a precise, detailed estimate and a report of where the modules below violate it.", + "- If you find more serious violations elsewhere during research, mention them in the report too.", + "- Show a summary of the report in chat.", + "- If any violation is found, suggest saving the report to a file as a plan for a detailed review, named `.code-ranker/-{id}.md`." ] } } }, - "plugin": "c", - "principles": [ - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/CPX.md", - "id": "CPX", - "label": "CPX", - "prompt": "These modules are too complex and I want to reduce their complexity.\nReduce it by splitting large units into smaller single-responsibility ones,\nextracting repeated patterns into shared helpers, flattening deeply nested\ncontrol flow, and breaking large functions into focused helpers.", - "sort_metric": "cognitive", - "title": "CPX — Reduce Complexity" - }, - { - "connections": [ - "common" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/ADP.md", - "id": "ADP", - "label": "ADP", - "prompt": "The dependency graph between modules must form a DAG. When module A depends\non module B, no chain of dependencies should bring B back to A.\n\nIdentify any cycles in the modules below. For each cycle, propose a concrete\nrefactoring (extract a shared abstraction, invert a dependency, split a module)\nthat makes the graph acyclic without breaking existing functionality.\n\nWhen splitting a module to break a cycle, the new structure should:\n- Preserve existing API contracts\n- Minimise coupling in the new structure\n- Follow the Single Responsibility Principle\n- Not introduce new dependency cycles", - "sort_metric": "cycle", - "title": "ADP — Acyclic Dependencies Principle" - }, - { - "connections": [ - "in", - "out" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/SRP.md", - "id": "SRP", - "label": "SRP", - "prompt": "A module should have one reason to change — it should serve one actor\nand encapsulate one coherent set of decisions.\n\nFor each module below, identify whether it has more than one responsibility.\nPropose how to split responsibilities so each module changes for only one reason,\nand specify the new module boundaries.", - "sort_metric": "sloc", - "title": "SRP — Single Responsibility Principle" - }, - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/OCP.md", - "id": "OCP", - "label": "OCP", - "prompt": "A module should be open for extension but closed for modification: new behaviour\nshould be addable without editing existing, working code.\n\nFor each module below, identify extension points that currently require editing\nexisting code (e.g. growing match/switch/if-else chains). Propose an extension\nmechanism (polymorphism, strategy, plug-in registration) so new cases can be added\nwithout modifying these modules.", - "sort_metric": "cyclomatic", - "title": "OCP — Open/Closed Principle" - }, - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/LSP.md", - "id": "LSP", - "label": "LSP", - "prompt": "Every implementation of an interface must honour its full contract — return-value\ninvariants, error/exception behaviour, side effects, and resource ownership — not\njust the method signatures. A subtype must be substitutable for its base without\nsurprising callers.\n\nIdentify the interface implementations in the modules below. For each, check it can\nreplace any other implementation of the same interface without breaking callers.\nFlag violations and propose fixes.", - "sort_metric": "hk", - "title": "LSP — Liskov Substitution Principle" - }, - { - "connections": [ - "in" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/ISP.md", - "id": "ISP", - "label": "ISP", - "prompt": "Clients should not be forced to depend on methods they do not use. Prefer several\nsmall, focused interfaces over one wide interface.\n\nIdentify interfaces in the modules below that are wider than their consumers need.\nPropose how to split them into narrower interfaces so each consumer depends only on\nwhat it actually uses.", - "sort_metric": "items", - "title": "ISP — Interface Segregation Principle" - }, - { - "connections": [ - "common", - "out" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/DIP.md", - "id": "DIP", - "label": "DIP", - "prompt": "High-level modules should not depend on low-level modules; both should depend on\nabstractions, and abstractions should not depend on details.\n\nFind places in the modules below where a high-level module depends directly on a\nconcrete low-level type. Propose an abstraction (interface) to invert each such\ndependency, and specify where the concrete implementation should be wired in.", - "sort_metric": "fan_out", - "title": "DIP — Dependency Inversion Principle" - }, - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/DRY.md", - "id": "DRY", - "label": "DRY", - "prompt": "Every piece of knowledge must have a single authoritative representation.\nDRY is about knowledge duplication, not just code duplication.\n\nIdentify concepts, rules, or policies that are duplicated across the modules\nbelow. For each duplication, propose a canonical location and the refactoring\nneeded to consolidate it.", - "sort_metric": "sloc", - "title": "DRY — Don't Repeat Yourself" - }, - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/KISS.md", - "id": "KISS", - "label": "KISS", - "prompt": "When two designs solve the same problem, prefer the simpler one — fewer\nabstractions, fewer indirection layers, fewer moving parts.\n\nIdentify over-engineered or needlessly complex constructs in the modules below.\nFor each, describe the simpler alternative and estimate the risk of simplifying.", - "sort_metric": "cognitive", - "title": "KISS — Keep It Simple" - }, - { - "connections": [ - "common", - "out" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/LoD.md", - "id": "LoD", - "label": "LoD", - "prompt": "A method should only call methods on: itself, its direct fields,\nits parameters, and objects it constructs locally.\nAvoid `x.foo().bar().baz()` chains that traverse object graphs.\n\nIdentify method chains or deep field traversals in the modules below that\nviolate LoD. For each, propose a narrow accessor or a facade that exposes only\nwhat the caller needs, reducing coupling.", - "sort_metric": "fan_out", - "title": "Law of Demeter — Principle of Least Knowledge" - }, - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/MISU.md", - "id": "MISU", - "label": "MISU", - "prompt": "Move correctness from runtime checks into the type system, so invalid states\ncannot be constructed and fail at compile time rather than at runtime.\n\nIdentify data structures or function signatures in the modules below where invalid\nstates are representable at runtime. For each, propose a type-level encoding\n(sum type / enum, newtype, typestate) that makes the invalid state unrepresentable\nby construction.", - "sort_metric": "cyclomatic", - "title": "MISU — Make Invalid States Unrepresentable" - }, - { - "connections": [ - "common" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/CoI.md", - "id": "CoI", - "label": "CoI", - "prompt": "Build behaviour by composing small, focused pieces rather than through deep\ninheritance hierarchies.\n\nIdentify large types that accumulate behaviour in the modules below. Propose how to\ndecompose them into smaller composable parts, and show how consumers would assemble\nthe behaviour they need.", - "sort_metric": "items", - "title": "CoI — Composition Over Inheritance" - }, - { - "connections": [ - "out" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/YAGNI.md", - "id": "YAGNI", - "label": "YAGNI", - "prompt": "Build for the problem you have now, not one you imagine you might have later.\nDon't add an abstraction, a generic parameter, or a public API for a hypothetical\nfuture use.\n\nIdentify abstractions, generics, or public APIs in the modules below that were\nadded speculatively. For each, assess whether multiple real callers use it today,\nand propose simplification if not.", - "sort_metric": "sloc", - "title": "YAGNI — You Aren't Gonna Need It" - } + "plugins": [ + "c" ], - "prompt": { - "cycle_note": "This is **one** dependency cycle; every module in it is listed below so the whole loop is visible. Fix one cycle at a time — `--top 2`+ lists several separate cycles at once and obscures how each one connects.", - "doc_note": "**First, before reading the source**, run `code-ranker docs {id}` — it prints the full principle and, for your language, the usual cause of this exact violation and the smallest correct fix, often with a worked example and how to confirm it. Read it first: it normally names the remedy outright, so you apply it instead of re-deriving the mechanism from the code.", - "focus": "**Focus the research and report primarily on the modules below.**", - "intro": "I want to apply this to some modules in my system.", - "task": [ - "- Prepare a precise, detailed estimate and a report of where the modules below violate it.", - "- If you find more serious violations elsewhere during research, mention them in the report too.", - "- Show a summary of the report in chat.", - "- If any violation is found, suggest saving the report to a file as a plan for a detailed review, named `.code-ranker/-{id}.md`." - ] - }, "roots": { "target": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/c/tests/sample" }, - "schema_version": "4.0", + "schema_version": "5.0", "target": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/c/tests/sample", "timings": [ { "detail": "6 nodes from 5 files", "ms": 0, - "stage": "c" + "stage": "c: parse" }, { "detail": "5 nodes annotated", "ms": 0, - "stage": "complexity" + "stage": "c: complexity" }, { "detail": "nodes=6 edges=5", "ms": 0, - "stage": "projection" + "stage": "c: projection" } ], "versions": { - "code-ranker": "4.0.0" + "code-ranker": "5.0.0" }, "workspace": "/home/user/code-ranker" } diff --git a/crates/code-ranker-plugins/src/languages/c/tests/sample/code-ranker.toml b/crates/code-ranker-plugins/src/languages/c/tests/sample/code-ranker.toml index f61f5861..c5dc5aab 100644 --- a/crates/code-ranker-plugins/src/languages/c/tests/sample/code-ranker.toml +++ b/crates/code-ranker-plugins/src/languages/c/tests/sample/code-ranker.toml @@ -1,6 +1,6 @@ -version = "4.0" +version = "5.0" # Self-contained config for the code-ranker "c" sample fixture. -plugin = "c" - -[ignore] +[plugins] +enabled = ["c"] +[plugins.base.ignore] tests = false diff --git a/crates/code-ranker-plugins/src/languages/cfamily/config.toml b/crates/code-ranker-plugins/src/languages/cfamily/config.toml index 475bc3e1..bec9cf3e 100644 --- a/crates/code-ranker-plugins/src/languages/cfamily/config.toml +++ b/crates/code-ranker-plugins/src/languages/cfamily/config.toml @@ -60,8 +60,3 @@ comment_kinds.named = ["comment"] # The generic, dialect-independent Halstead totals wording. The distinct-count # η₁/η₂ descriptions (which enumerate each dialect's exact tokens) differ and # live in each `/config.toml`. -[specs.n1] -description = "Total operators (N₁): every operator occurrence counted with repetition (the η₁ tokens, not deduplicated)." - -[specs.n2] -description = "Total operands (N₂): every operand occurrence counted with repetition (the η₂ texts, not deduplicated)." diff --git a/crates/code-ranker-plugins/src/languages/cpp/config.toml b/crates/code-ranker-plugins/src/languages/cpp/config.toml index 41415a58..ad810502 100644 --- a/crates/code-ranker-plugins/src/languages/cpp/config.toml +++ b/crates/code-ranker-plugins/src/languages/cpp/config.toml @@ -13,6 +13,8 @@ doc_lang = "cpp" # shared and inherited from `../cfamily/config.toml`. extensions = ["cpp", "cc", "cxx", "hpp", "hh", "hxx", "ipp"] test_suffixes = ["_test.cpp", "_test.cc", "_test.cxx"] +# Short aliases accepted anywhere a language is named. Unique across languages. +aliases = ["c++", "cxx"] # `default` (a plain function) is inherited from cfamily; C++ adds `method`. [units] diff --git a/crates/code-ranker-plugins/src/languages/cpp/mod.rs b/crates/code-ranker-plugins/src/languages/cpp/mod.rs index 564e26ca..e24b1322 100644 --- a/crates/code-ranker-plugins/src/languages/cpp/mod.rs +++ b/crates/code-ranker-plugins/src/languages/cpp/mod.rs @@ -29,7 +29,6 @@ static CONFIG: LazyLock = LazyLock::new(|| { include_str!("config.toml"), ]) }); -static CFG: LazyLock = LazyLock::new(|| cfamily::Cfg::from_config(&CONFIG)); // Self-register this plugin (collected by `code_ranker_plugin_api::registry`); no // central list anywhere names a language. @@ -49,16 +48,17 @@ impl LanguagePlugin for CppPlugin { "cpp" } - fn detect(&self, workspace: &Path, input: &PluginInput) -> bool { - cfamily::detect(workspace, &CFG, &crate::walk::ignore_from(input)) + fn detect(&self, cfg: &toml::Table, workspace: &Path, input: &PluginInput) -> bool { + let c = cfamily::Cfg::from_config(cfg); + cfamily::detect(workspace, &c, &crate::walk::ignore_from(input)) } - fn levels(&self) -> Vec { + fn levels(&self, cfg: &toml::Table) -> Vec { vec![ Level { name: "files".into(), - edge_kinds: crate::config::edge_kinds(&CONFIG), - node_attributes: crate::config::node_attributes(&CONFIG), + edge_kinds: crate::config::edge_kinds(cfg), + node_attributes: crate::config::node_attributes(cfg), edge_attributes: BTreeMap::new(), attribute_groups: BTreeMap::new(), node_kinds: default_node_kinds(), @@ -71,43 +71,48 @@ impl LanguagePlugin for CppPlugin { node_attributes: BTreeMap::new(), edge_attributes: BTreeMap::new(), attribute_groups: BTreeMap::new(), - node_kinds: crate::config::node_kinds(&CONFIG), + node_kinds: crate::config::node_kinds(cfg), cycle_kinds: default_cycle_kinds(), grouping: None, }, ] } - fn analyze(&self, workspace: &Path, input: &PluginInput) -> Result { + fn analyze(&self, cfg: &toml::Table, workspace: &Path, input: &PluginInput) -> Result { + let c = cfamily::Cfg::from_config(cfg); cfamily::analyze( workspace, input.ignore_tests, - &CFG, + &c, &crate::walk::ignore_from(input), ) } - fn metrics(&self, graph: &Graph) -> Vec<(String, MetricInputs)> { + fn metrics(&self, _cfg: &toml::Table, graph: &Graph) -> Vec<(String, MetricInputs)> { file_metrics(graph) } - fn function_units(&self, graph: &Graph) -> Vec<(Node, MetricInputs)> { + fn function_units(&self, _cfg: &toml::Table, graph: &Graph) -> Vec<(Node, MetricInputs)> { function_nodes(graph) } - fn principles(&self, _input: &PluginInput) -> Vec { - crate::config::resolved_principles(&CONFIG) + fn principles(&self, cfg: &toml::Table, _input: &PluginInput) -> Vec { + crate::config::resolved_principles(cfg) } - fn report_overrides(&self) -> code_ranker_plugin_api::report::ReportOverride { - code_ranker_plugin_api::list_override::report_override(&CONFIG) + fn report_overrides( + &self, + cfg: &toml::Table, + ) -> code_ranker_plugin_api::report::ReportOverride { + code_ranker_plugin_api::list_override::report_override(cfg) } fn metric_specs( &self, + cfg: &toml::Table, defaults: BTreeMap, ) -> BTreeMap { - crate::config::apply_spec_overrides(defaults, &CONFIG) + crate::config::apply_spec_overrides(defaults, cfg) } } diff --git a/crates/code-ranker-plugins/src/languages/cpp/tests/mod_rs.rs b/crates/code-ranker-plugins/src/languages/cpp/tests/mod_rs.rs index 735754f3..72599f33 100644 --- a/crates/code-ranker-plugins/src/languages/cpp/tests/mod_rs.rs +++ b/crates/code-ranker-plugins/src/languages/cpp/tests/mod_rs.rs @@ -6,9 +6,10 @@ use super::*; fn detects_by_cpp_source_presence() { let d = tempfile::tempdir().unwrap(); let p = CppPlugin; - assert!(!p.detect(d.path(), &PluginInput::default())); + let cfg = p.config(); + assert!(!p.detect(&cfg, d.path(), &PluginInput::default())); std::fs::write(d.path().join("main.cpp"), "int main(){return 0;}\n").unwrap(); - assert!(p.detect(d.path(), &PluginInput::default())); + assert!(p.detect(&cfg, d.path(), &PluginInput::default())); assert_eq!(p.name(), "cpp"); } @@ -21,9 +22,14 @@ fn metrics_and_function_units_over_a_temp_project() { ) .unwrap(); let p = CppPlugin; - let g = p.analyze(d.path(), &PluginInput::default()).unwrap(); - assert!(!p.metrics(&g).is_empty()); - assert!(p.function_units(&g).iter().any(|(n, _)| n.name == "add")); + let cfg = p.config(); + let g = p.analyze(&cfg, d.path(), &PluginInput::default()).unwrap(); + assert!(!p.metrics(&cfg, &g).is_empty()); + assert!( + p.function_units(&cfg, &g) + .iter() + .any(|(n, _)| n.name == "add") + ); } #[test] @@ -45,6 +51,7 @@ fn metrics_skip_non_file_and_unreadable_nodes() { ], edges: vec![], }; - assert!(CppPlugin.metrics(&g).is_empty()); - assert!(CppPlugin.function_units(&g).is_empty()); + let cfg = CppPlugin.config(); + assert!(CppPlugin.metrics(&cfg, &g).is_empty()); + assert!(CppPlugin.function_units(&cfg, &g).is_empty()); } diff --git a/crates/code-ranker-plugins/src/languages/cpp/tests/sample/code-ranker-check.sarif b/crates/code-ranker-plugins/src/languages/cpp/tests/sample/code-ranker-check.sarif index 03beb451..b2abc2cf 100644 --- a/crates/code-ranker-plugins/src/languages/cpp/tests/sample/code-ranker-check.sarif +++ b/crates/code-ranker-plugins/src/languages/cpp/tests/sample/code-ranker-check.sarif @@ -8,7 +8,7 @@ "informationUri": "https://github.com/ffedoroff/code-ranker", "name": "code-ranker", "rules": [], - "version": "3.0.0-alpha.1" + "version": "4.0.0" } } } diff --git a/crates/code-ranker-plugins/src/languages/cpp/tests/sample/code-ranker-report.json b/crates/code-ranker-plugins/src/languages/cpp/tests/sample/code-ranker-report.json index d39ee75a..4a9d9ac4 100644 --- a/crates/code-ranker-plugins/src/languages/cpp/tests/sample/code-ranker-report.json +++ b/crates/code-ranker-plugins/src/languages/cpp/tests/sample/code-ranker-report.json @@ -1,5 +1,5 @@ { - "command": "code-ranker report crates/code-ranker-plugins/src/languages/cpp/tests/sample --config crates/code-ranker-plugins/src/languages/cpp/tests/sample/code-ranker.toml --output.json.path=crates/code-ranker-plugins/src/languages/cpp/tests/sample/code-ranker-report.json --output.mode quiet", + "command": "code-ranker report crates/code-ranker-plugins/src/languages/cpp/tests/sample --config crates/code-ranker-plugins/src/languages/cpp/tests/sample/code-ranker.toml --output.json.path=crates/code-ranker-plugins/src/languages/cpp/tests/sample/code-ranker-report.json", "config_file": "crates/code-ranker-plugins/src/languages/cpp/tests/sample/code-ranker.toml", "generated_at": "1970-01-01T00:00:00Z", "git": { @@ -8,775 +8,781 @@ "dirty_files": 0, "origin": "git@example.com:org/repo.git" }, - "graphs": { - "files": { - "attribute_groups": { - "complexity": { - "description": "per-function branching, nesting & size", - "label": "Complexity" - }, - "coupling": { - "description": "how tightly modules depend on each other", - "label": "Coupling" - }, - "halstead": { - "description": "operator/operand vocabulary & derived effort", - "label": "Halstead" - }, - "loc": { - "description": "physical line counts", - "label": "Lines of Code" - }, - "maintainability": { - "description": "composite score", - "label": "Maintainability" - } - }, - "cycle_kinds": {}, - "cycles": [], - "edge_attributes": {}, - "edge_kinds": { - "uses": { - "description": "Import dependency — this file imports from the other.", - "flow": true, - "label": "uses" + "languages": { + "cpp": { + "graphs": { + "files": { + "attribute_groups": { + "complexity": { + "description": "per-function branching, nesting & size", + "label": "Complexity" + }, + "coupling": { + "description": "how tightly modules depend on each other", + "label": "Coupling" + }, + "halstead": { + "description": "operator/operand vocabulary & derived effort", + "label": "Halstead" + }, + "loc": { + "description": "physical line counts", + "label": "Lines of Code" + }, + "maintainability": { + "description": "composite score", + "label": "Maintainability" + } + }, + "cycle_kinds": {}, + "cycles": [], + "edge_attributes": {}, + "edge_kinds": { + "uses": { + "description": "Import dependency — this file imports from the other.", + "flow": true, + "label": "uses" + } + }, + "edges": [ + { + "kind": "uses", + "line": 1, + "source": "{target}/main.cpp", + "target": "ext:vector" + }, + { + "kind": "uses", + "line": 2, + "source": "{target}/main.cpp", + "target": "{target}/mathx.hpp" + }, + { + "kind": "uses", + "line": 1, + "source": "{target}/mathx.cpp", + "target": "{target}/mathx.hpp" + }, + { + "kind": "uses", + "line": 5, + "source": "{target}/mathx.hpp", + "target": "{target}/util.hpp" + }, + { + "kind": "uses", + "line": 1, + "source": "{target}/util.cpp", + "target": "{target}/util.hpp" + } + ], + "node_attributes": { + "args": { + "description": "Number of function / closure arguments.", + "direction": "lower_better", + "group": "complexity", + "label": "Args", + "name": "Arguments", + "short": "Args", + "value_type": "int" + }, + "blank": { + "description": "Empty or whitespace-only lines.", + "group": "loc", + "label": "Blank", + "name": "Blank lines", + "short": "Blank", + "value_type": "int" + }, + "branches": { + "description": "Decision points: if / for / while / loop / match arm / try / && / ||. Feeds `cyclomatic`.", + "direction": "lower_better", + "group": "complexity", + "label": "Branches", + "name": "Decision points", + "short": "Branches", + "value_type": "int" + }, + "bugs": { + "calc": "effort ** (2/3) / 3000", + "description": "Estimated delivered bugs — a rough predictor of defect density.", + "direction": "lower_better", + "formula": "effort^⅔ ÷ 3000", + "group": "halstead", + "label": "Bugs", + "name": "Estimated bugs", + "short": "H.bugs", + "value_type": "float" + }, + "cloc": { + "description": "Comment-only lines (inline comments on code lines are not counted).", + "group": "loc", + "label": "Comments", + "name": "Comment lines", + "short": "Comments", + "value_type": "int" + }, + "closures": { + "description": "Number of closures defined in the unit.", + "direction": "lower_better", + "group": "complexity", + "label": "Closures", + "name": "Closures defined", + "short": "Closures", + "value_type": "int" + }, + "cognitive": { + "description": "How hard the code is for a human to follow — not just how many paths it has.
Like `cyclomatic` it adds +1 for each break in linear flow (`if`, `else`, `match`, loops, `catch`, chained `&&` / `||`), but it also adds an extra +1 for every level of nesting: an `if` inside a loop inside an `if` costs far more than three flat `if`s.
That nesting penalty is the point — deeply indented logic is what actually strains a reader, so a high `cognitive` next to a modest `cyclomatic` flags tangled, hard-to-read code.
Summed across every function in the file.", + "direction": "lower_better", + "group": "complexity", + "label": "Cognitive", + "name": "Cognitive complexity", + "short": "Cognitive", + "value_type": "int" + }, + "cyclomatic": { + "calc": "spaces + branches", + "description": "Number of independent paths through the code — roughly the minimum number of test cases needed to cover every branch.
A function starts at 1 and gains +1 per decision point: each `if` / `else if`, every `match` / `switch` arm, every loop, and each `&&` / `||` in a condition.
Summed across every function in the file, so it grows with both size and branching — the file's total branching burden.
Counts paths only, ignoring how deeply they nest. For a readability-weighted view see `cognitive`.", + "direction": "lower_better", + "formula": "spaces + branches", + "group": "complexity", + "label": "Cyclomatic", + "name": "Cyclomatic complexity", + "omit_at": 1.0, + "short": "Cyclomatic", + "value_type": "int" + }, + "effort": { + "calc": "(eta1 / 2) * (n2 / eta2) * volume", + "description": "Mental effort to implement the algorithm.", + "direction": "lower_better", + "formula": "(eta1 ÷ 2) × (n2 ÷ eta2) × volume", + "group": "halstead", + "label": "Effort", + "name": "Implementation effort", + "short": "H.effort", + "value_type": "float" + }, + "eta1": { + "description": "Distinct operators (η₁): the count of unique operator token kinds. C++ counts the C operators plus `::`, `new` / `delete`, `throw` / `try` / `catch`, and the keywords `class namespace template typename using virtual override explicit friend operator public private protected constexpr` (alongside the C control / type keywords and the preprocessor directives).", + "direction": "lower_better", + "group": "halstead", + "label": "η₁", + "name": "Unique operators", + "short": "η₁", + "value_type": "int" + }, + "eta2": { + "description": "Distinct operands (η₂): the count of unique operand texts. C++ counts identifiers (incl. field / qualified / type identifiers), literals (number, char, string, raw-string), primitive type names, and `true` / `false` / `nullptr` / `this`.", + "direction": "lower_better", + "group": "halstead", + "label": "η₂", + "name": "Unique operands", + "short": "η₂", + "value_type": "int" + }, + "exits": { + "description": "Number of exit points (return/throw) in the unit.", + "direction": "lower_better", + "group": "complexity", + "label": "Exits", + "name": "Exit points", + "short": "Exits", + "value_type": "int" + }, + "external": { + "label": "External", + "value_type": "bool" + }, + "fan_in": { + "description": "Many other units depend on this one, making it risky to change and a single point of failure — though some hubs (shared types) carry high fan-in legitimately.", + "group": "coupling", + "label": "Fan-in", + "name": "Incoming dependencies", + "short": "Fan-in", + "value_type": "int" + }, + "fan_out": { + "description": "This unit depends on many others, so it breaks when any of them change and is hard to test in isolation.", + "group": "coupling", + "label": "Fan-out", + "name": "Outgoing dependencies", + "short": "Fan-out", + "value_type": "int" + }, + "fan_out_external": { + "description": "Number of distinct external libraries this node depends on.", + "group": "coupling", + "label": "Fan-out (external)", + "name": "External dependencies", + "short": "Fan-out (external)", + "value_type": "int" + }, + "hk": { + "abbreviate": true, + "calc": "sloc * (fan_in * fan_out) ** 2", + "description": "Henry-Kafura information-flow complexity: a module that is both a busy crossroads (high fan-in × fan-out) and large — the most expensive place in the codebase to change.", + "direction": "lower_better", + "formula": "sloc × (fan_in × fan_out)²", + "group": "coupling", + "label": "HK", + "name": "God-object risk", + "short": "HK", + "value_type": "float" + }, + "length": { + "calc": "n1 + n2", + "description": "Program length — total operator + operand occurrences.", + "direction": "lower_better", + "formula": "n1 + n2", + "group": "halstead", + "label": "Length", + "name": "Total tokens", + "short": "H.len", + "value_type": "float" + }, + "lloc": { + "description": "Logical lines — counts statements, not physical lines.", + "group": "loc", + "label": "Logical", + "name": "Logical lines", + "short": "Logical", + "value_type": "int" + }, + "loc": { + "description": "Raw file line count, including blank and comment lines (unlike `sloc`). Large files tend to hold several responsibilities and are harder to review, test, and reuse.", + "group": "loc", + "label": "Lines", + "name": "Total lines", + "remediation": "Split by responsibility into smaller units, extract helpers, and separate data definitions from behavior. For an average breach, break up the largest units first (--top).", + "value_type": "int" + }, + "mi": { + "calc": "171 - 5.2*Math.log(volume) - 0.23*cyclomatic - 16.2*Math.log(span_sloc)", + "description": "Maintainability Index (0–100, higher is more maintainable). Derived from Halstead volume, cyclomatic complexity, and SLOC.", + "direction": "higher_better", + "formula": "171 − 5.2·ln(volume) − 0.23·cyclomatic − 16.2·ln(span_sloc)", + "group": "maintainability", + "label": "MI", + "name": "Maintainability index", + "short": "MI", + "value_type": "float" + }, + "mi_sei": { + "calc": "171 - 5.2*Math.log2(volume) - 0.23*cyclomatic - 16.2*Math.log2(span_sloc) + 50*Math.sin(Math.sqrt(cloc / span_sloc * 2.4))", + "description": "SEI variant of the Maintainability Index — adds a bonus for comment density.", + "direction": "higher_better", + "formula": "171 − 5.2·log₂(volume) − 0.23·cyclomatic − 16.2·log₂(span_sloc) + 50·sin(√(cloc ÷ span_sloc × 2.4))", + "group": "maintainability", + "label": "MI (SEI)", + "name": "Maintainability (SEI)", + "short": "MI SEI", + "value_type": "float" + }, + "n1": { + "description": "Total operators (N₁): every operator occurrence counted with repetition (the η₁ tokens, not deduplicated).", + "direction": "lower_better", + "group": "halstead", + "label": "N₁", + "name": "Total operators", + "short": "N₁", + "value_type": "int" + }, + "n2": { + "description": "Total operands (N₂): every operand occurrence counted with repetition (the η₂ texts, not deduplicated).", + "direction": "lower_better", + "group": "halstead", + "label": "N₂", + "name": "Total operands", + "short": "N₂", + "value_type": "int" + }, + "sloc": { + "description": "Source lines of code — lines with at least one non-whitespace, non-comment character. Blank and comment-only lines are not counted (unlike `loc`, the raw file line count).", + "group": "loc", + "label": "Source", + "name": "Source lines", + "short": "SLOC", + "value_type": "int" + }, + "spaces": { + "description": "Unit count: the source file (1) plus each function / impl / trait / closure space. Feeds `cyclomatic`.", + "direction": "lower_better", + "group": "complexity", + "label": "Spaces", + "name": "Code units", + "short": "Spaces", + "value_type": "int" + }, + "span_sloc": { + "description": "Line span of the unit (end_row − start_row) — the size input the Maintainability Index (`mi` / `mi_sei`) is computed from.", + "direction": "lower_better", + "group": "maintainability", + "label": "Span", + "name": "Line span", + "short": "Span", + "value_type": "int" + }, + "time": { + "calc": "effort / 18", + "description": "Estimated implementation time, in seconds.", + "direction": "lower_better", + "formula": "effort ÷ 18", + "group": "halstead", + "label": "Time", + "name": "Coding time (s)", + "short": "H.time(s)", + "value_type": "float" + }, + "vocabulary": { + "calc": "eta1 + eta2", + "description": "Vocabulary — distinct operators + operands.", + "direction": "lower_better", + "formula": "eta1 + eta2", + "group": "halstead", + "label": "Vocabulary", + "name": "Distinct symbols", + "short": "H.vocab", + "value_type": "float" + }, + "volume": { + "calc": "length * Math.log2(vocabulary)", + "description": "Algorithm size in bits, from distinct operators and operands.", + "direction": "lower_better", + "formula": "length × log₂(vocabulary)", + "group": "halstead", + "label": "Volume", + "name": "Code volume", + "short": "H.vol", + "value_type": "float" + } + }, + "node_kinds": { + "external": { + "external": true, + "fill": "#f6e2c0", + "label": "Library", + "plural": "Libraries", + "stroke": "#b3801f" + }, + "file": { + "fill": "#dbe9f4", + "label": "File", + "plural": "Files", + "stroke": "#4d6f9c" + } + }, + "nodes": [ + { + "external": true, + "id": "ext:vector", + "kind": "external", + "name": "vector" + }, + { + "args": 1, + "blank": 1, + "branches": 3, + "bugs": 0.101, + "cloc": 1, + "closures": 1, + "cognitive": 4, + "cyclomatic": 6, + "effort": 5278.475, + "eta1": 16, + "eta2": 12, + "exits": 2, + "fan_out": 1, + "fan_out_external": 1, + "id": "{target}/main.cpp", + "kind": "file", + "length": 61, + "lloc": 10, + "loc": 14, + "mi": 97.325, + "mi_sei": 85.437, + "n1": 34, + "n2": 27, + "name": "main.cpp", + "sloc": 12, + "spaces": 3, + "span_sloc": 14, + "time": 293.248, + "vocabulary": 28, + "volume": 293.248 + }, + { + "args": 1, + "blank": 1, + "bugs": 0.0234, + "cloc": 1, + "cyclomatic": 2, + "effort": 589.382, + "eta1": 8, + "eta2": 7, + "exits": 1, + "fan_out": 1, + "id": "{target}/mathx.cpp", + "kind": "file", + "length": 22, + "lloc": 3, + "loc": 7, + "mi": 115.856, + "mi_sei": 119.28, + "n1": 10, + "n2": 12, + "name": "mathx.cpp", + "sloc": 5, + "spaces": 2, + "span_sloc": 7, + "time": 32.743, + "vocabulary": 15, + "volume": 85.951 + }, + { + "blank": 3, + "bugs": 0.0109, + "cloc": 1, + "effort": 188.884, + "eta1": 6, + "eta2": 5, + "fan_in": 2, + "fan_out": 1, + "hk": 20, + "id": "{target}/mathx.hpp", + "kind": "file", + "length": 13, + "lloc": 3, + "loc": 9, + "mi": 115.383, + "mi_sei": 115.551, + "n1": 6, + "n2": 7, + "name": "mathx.hpp", + "sloc": 5, + "spaces": 1, + "span_sloc": 9, + "time": 10.493, + "vocabulary": 11, + "volume": 44.972 + }, + { + "args": 2, + "blank": 1, + "branches": 1, + "bugs": 0.0274, + "cloc": 1, + "cognitive": 1, + "cyclomatic": 3, + "effort": 748.968, + "eta1": 8, + "eta2": 5, + "exits": 2, + "fan_out": 1, + "id": "{target}/util.cpp", + "kind": "file", + "length": 23, + "lloc": 4, + "loc": 9, + "mi": 111.606, + "mi_sei": 110.306, + "n1": 12, + "n2": 11, + "name": "util.cpp", + "sloc": 7, + "spaces": 2, + "span_sloc": 9, + "time": 41.609, + "vocabulary": 13, + "volume": 85.11 + }, + { + "blank": 2, + "bugs": 0.0126, + "cloc": 1, + "effort": 232.473, + "eta1": 6, + "eta2": 5, + "fan_in": 2, + "id": "{target}/util.hpp", + "kind": "file", + "length": 14, + "lloc": 2, + "loc": 7, + "mi": 119.069, + "mi_sei": 123.814, + "n1": 6, + "n2": 8, + "name": "util.hpp", + "sloc": 4, + "spaces": 1, + "span_sloc": 7, + "time": 12.915, + "vocabulary": 11, + "volume": 48.432 + } + ], + "stats": { + "blank": 1.6, + "bugs": 0.035, + "cloc": 1, + "cognitive": 2.5, + "cyclomatic": 3.666, + "effort": 1407.636, + "fan_in": 2, + "fan_out": 1, + "hk": 20, + "length": 26.6, + "mi": 111.847, + "mi_sei": 110.877, + "sloc": 6.6, + "time": 78.201, + "vocabulary": 15.6, + "volume": 111.542 + }, + "ui": { + "card": [ + "hk", + "sloc" + ], + "columns": [ + "kind", + "sloc", + "hk", + "fan_in", + "fan_out", + "volume", + "bugs", + "effort", + "time", + "length", + "vocabulary", + "cyclomatic", + "cognitive", + "mi", + "mi_sei", + "lloc", + "cloc", + "blank" + ], + "default_sort": "hk", + "filter": [], + "size": [ + "sloc", + "hk" + ], + "sort": [ + "sloc", + "hk", + "fan_in", + "fan_out", + "volume", + "bugs", + "effort", + "time", + "length", + "vocabulary", + "cyclomatic", + "cognitive", + "mi", + "mi_sei", + "lloc", + "cloc", + "blank" + ], + "summary": [ + "sloc", + "hk", + "fan_in", + "fan_out", + "volume", + "bugs", + "effort", + "time", + "length", + "vocabulary", + "cyclomatic", + "cognitive", + "mi", + "mi_sei", + "lloc", + "cloc", + "blank" + ] + } } }, - "edges": [ + "principles": [ { - "kind": "uses", - "line": 1, - "source": "{target}/main.cpp", - "target": "ext:vector" + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/CPX.md", + "id": "CPX", + "label": "CPX", + "prompt": "These modules are too complex and I want to reduce their complexity.\nReduce it by splitting large units into smaller single-responsibility ones,\nextracting repeated patterns into shared helpers, flattening deeply nested\ncontrol flow, and breaking large functions into focused helpers.", + "sort_metric": "cognitive", + "title": "CPX — Reduce Complexity" }, { - "kind": "uses", - "line": 2, - "source": "{target}/main.cpp", - "target": "{target}/mathx.hpp" + "connections": [ + "common" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/ADP.md", + "id": "ADP", + "label": "ADP", + "prompt": "The dependency graph between modules must form a DAG. When module A depends\non module B, no chain of dependencies should bring B back to A.\n\nIdentify any cycles in the modules below. For each cycle, propose a concrete\nrefactoring (extract a shared abstraction, invert a dependency, split a module)\nthat makes the graph acyclic without breaking existing functionality.\n\nWhen splitting a module to break a cycle, the new structure should:\n- Preserve existing API contracts\n- Minimise coupling in the new structure\n- Follow the Single Responsibility Principle\n- Not introduce new dependency cycles", + "sort_metric": "cycle", + "title": "ADP — Acyclic Dependencies Principle" }, { - "kind": "uses", - "line": 1, - "source": "{target}/mathx.cpp", - "target": "{target}/mathx.hpp" + "connections": [ + "in", + "out" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/SRP.md", + "id": "SRP", + "label": "SRP", + "prompt": "A module should have one reason to change — it should serve one actor\nand encapsulate one coherent set of decisions.\n\nFor each module below, identify whether it has more than one responsibility.\nPropose how to split responsibilities so each module changes for only one reason,\nand specify the new module boundaries.", + "sort_metric": "sloc", + "title": "SRP — Single Responsibility Principle" }, { - "kind": "uses", - "line": 5, - "source": "{target}/mathx.hpp", - "target": "{target}/util.hpp" + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/OCP.md", + "id": "OCP", + "label": "OCP", + "prompt": "A module should be open for extension but closed for modification: new behaviour\nshould be addable without editing existing, working code.\n\nFor each module below, identify extension points that currently require editing\nexisting code (e.g. growing match/switch/if-else chains). Propose an extension\nmechanism (polymorphism, strategy, plug-in registration) so new cases can be added\nwithout modifying these modules.", + "sort_metric": "cyclomatic", + "title": "OCP — Open/Closed Principle" }, { - "kind": "uses", - "line": 1, - "source": "{target}/util.cpp", - "target": "{target}/util.hpp" - } - ], - "node_attributes": { - "args": { - "description": "Number of function / closure arguments.", - "direction": "lower_better", - "group": "complexity", - "label": "Args", - "name": "Arguments", - "short": "Args", - "value_type": "int" - }, - "blank": { - "description": "Empty or whitespace-only lines.", - "group": "loc", - "label": "Blank", - "name": "Blank lines", - "short": "Blank", - "value_type": "int" - }, - "branches": { - "description": "Decision points: if / for / while / loop / match arm / try / && / ||. Feeds `cyclomatic`.", - "direction": "lower_better", - "group": "complexity", - "label": "Branches", - "name": "Decision points", - "short": "Branches", - "value_type": "int" - }, - "bugs": { - "calc": "effort ** (2/3) / 3000", - "description": "Estimated delivered bugs — a rough predictor of defect density.", - "direction": "lower_better", - "formula": "effort^⅔ ÷ 3000", - "group": "halstead", - "label": "Bugs", - "name": "Estimated bugs", - "short": "H.bugs", - "value_type": "float" - }, - "cloc": { - "description": "Comment-only lines (inline comments on code lines are not counted).", - "group": "loc", - "label": "Comments", - "name": "Comment lines", - "short": "Comments", - "value_type": "int" - }, - "closures": { - "description": "Number of closures defined in the unit.", - "direction": "lower_better", - "group": "complexity", - "label": "Closures", - "name": "Closures defined", - "short": "Closures", - "value_type": "int" - }, - "cognitive": { - "description": "How hard the code is for a human to follow — not just how many paths it has.
Like `cyclomatic` it adds +1 for each break in linear flow (`if`, `else`, `match`, loops, `catch`, chained `&&` / `||`), but it also adds an extra +1 for every level of nesting: an `if` inside a loop inside an `if` costs far more than three flat `if`s.
That nesting penalty is the point — deeply indented logic is what actually strains a reader, so a high `cognitive` next to a modest `cyclomatic` flags tangled, hard-to-read code.
Summed across every function in the file.", - "direction": "lower_better", - "group": "complexity", - "label": "Cognitive", - "name": "Cognitive complexity", - "short": "Cognitive", - "value_type": "int" - }, - "cyclomatic": { - "calc": "spaces + branches", - "description": "Number of independent paths through the code — roughly the minimum number of test cases needed to cover every branch.
A function starts at 1 and gains +1 per decision point: each `if` / `else if`, every `match` / `switch` arm, every loop, and each `&&` / `||` in a condition.
Summed across every function in the file, so it grows with both size and branching — the file's total branching burden.
Counts paths only, ignoring how deeply they nest. For a readability-weighted view see `cognitive`.", - "direction": "lower_better", - "formula": "spaces + branches", - "group": "complexity", - "label": "Cyclomatic", - "name": "Cyclomatic complexity", - "omit_at": 1.0, - "short": "Cyclomatic", - "value_type": "int" - }, - "effort": { - "calc": "(eta1 / 2) * (n2 / eta2) * volume", - "description": "Mental effort to implement the algorithm.", - "direction": "lower_better", - "formula": "(eta1 ÷ 2) × (n2 ÷ eta2) × volume", - "group": "halstead", - "label": "Effort", - "name": "Implementation effort", - "short": "H.effort", - "value_type": "float" - }, - "eta1": { - "description": "Distinct operators (η₁): the count of unique operator token kinds. C++ counts the C operators plus `::`, `new` / `delete`, `throw` / `try` / `catch`, and the keywords `class namespace template typename using virtual override explicit friend operator public private protected constexpr` (alongside the C control / type keywords and the preprocessor directives).", - "direction": "lower_better", - "group": "halstead", - "label": "η₁", - "name": "Unique operators", - "short": "η₁", - "value_type": "int" - }, - "eta2": { - "description": "Distinct operands (η₂): the count of unique operand texts. C++ counts identifiers (incl. field / qualified / type identifiers), literals (number, char, string, raw-string), primitive type names, and `true` / `false` / `nullptr` / `this`.", - "direction": "lower_better", - "group": "halstead", - "label": "η₂", - "name": "Unique operands", - "short": "η₂", - "value_type": "int" - }, - "exits": { - "description": "Number of exit points (return/throw) in the unit.", - "direction": "lower_better", - "group": "complexity", - "label": "Exits", - "name": "Exit points", - "short": "Exits", - "value_type": "int" + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/LSP.md", + "id": "LSP", + "label": "LSP", + "prompt": "Every implementation of an interface must honour its full contract — return-value\ninvariants, error/exception behaviour, side effects, and resource ownership — not\njust the method signatures. A subtype must be substitutable for its base without\nsurprising callers.\n\nIdentify the interface implementations in the modules below. For each, check it can\nreplace any other implementation of the same interface without breaking callers.\nFlag violations and propose fixes.", + "sort_metric": "hk", + "title": "LSP — Liskov Substitution Principle" }, - "external": { - "label": "External", - "value_type": "bool" - }, - "fan_in": { - "description": "Many other units depend on this one, making it risky to change and a single point of failure — though some hubs (shared types) carry high fan-in legitimately.", - "group": "coupling", - "label": "Fan-in", - "name": "Incoming dependencies", - "short": "Fan-in", - "value_type": "int" - }, - "fan_out": { - "description": "This unit depends on many others, so it breaks when any of them change and is hard to test in isolation.", - "group": "coupling", - "label": "Fan-out", - "name": "Outgoing dependencies", - "short": "Fan-out", - "value_type": "int" - }, - "fan_out_external": { - "description": "Number of distinct external libraries this node depends on.", - "group": "coupling", - "label": "Fan-out (external)", - "name": "External dependencies", - "short": "Fan-out (external)", - "value_type": "int" - }, - "hk": { - "abbreviate": true, - "calc": "sloc * (fan_in * fan_out) ** 2", - "description": "Henry-Kafura information-flow complexity: a module that is both a busy crossroads (high fan-in × fan-out) and large — the most expensive place in the codebase to change.", - "direction": "lower_better", - "formula": "sloc × (fan_in × fan_out)²", - "group": "coupling", - "label": "HK", - "name": "God-object risk", - "short": "HK", - "value_type": "float" - }, - "length": { - "calc": "n1 + n2", - "description": "Program length — total operator + operand occurrences.", - "direction": "lower_better", - "formula": "n1 + n2", - "group": "halstead", - "label": "Length", - "name": "Total tokens", - "short": "H.len", - "value_type": "float" - }, - "lloc": { - "description": "Logical lines — counts statements, not physical lines.", - "group": "loc", - "label": "Logical", - "name": "Logical lines", - "short": "Logical", - "value_type": "int" - }, - "loc": { - "description": "Raw file line count, including blank and comment lines (unlike `sloc`). Large files tend to hold several responsibilities and are harder to review, test, and reuse.", - "group": "loc", - "label": "Lines", - "name": "Total lines", - "remediation": "Split by responsibility into smaller units, extract helpers, and separate data definitions from behavior. For an average breach, break up the largest units first (--top).", - "value_type": "int" - }, - "mi": { - "calc": "171 - 5.2*Math.log(volume) - 0.23*cyclomatic - 16.2*Math.log(span_sloc)", - "description": "Maintainability Index (0–100, higher is more maintainable). Derived from Halstead volume, cyclomatic complexity, and SLOC.", - "direction": "higher_better", - "formula": "171 − 5.2·ln(volume) − 0.23·cyclomatic − 16.2·ln(span_sloc)", - "group": "maintainability", - "label": "MI", - "name": "Maintainability index", - "short": "MI", - "value_type": "float" - }, - "mi_sei": { - "calc": "171 - 5.2*Math.log2(volume) - 0.23*cyclomatic - 16.2*Math.log2(span_sloc) + 50*Math.sin(Math.sqrt(cloc / span_sloc * 2.4))", - "description": "SEI variant of the Maintainability Index — adds a bonus for comment density.", - "direction": "higher_better", - "formula": "171 − 5.2·log₂(volume) − 0.23·cyclomatic − 16.2·log₂(span_sloc) + 50·sin(√(cloc ÷ span_sloc × 2.4))", - "group": "maintainability", - "label": "MI (SEI)", - "name": "Maintainability (SEI)", - "short": "MI SEI", - "value_type": "float" - }, - "n1": { - "description": "Total operators (N₁): every operator occurrence counted with repetition (the η₁ tokens, not deduplicated).", - "direction": "lower_better", - "group": "halstead", - "label": "N₁", - "name": "Total operators", - "short": "N₁", - "value_type": "int" - }, - "n2": { - "description": "Total operands (N₂): every operand occurrence counted with repetition (the η₂ texts, not deduplicated).", - "direction": "lower_better", - "group": "halstead", - "label": "N₂", - "name": "Total operands", - "short": "N₂", - "value_type": "int" - }, - "sloc": { - "description": "Source lines of code — lines with at least one non-whitespace, non-comment character. Blank and comment-only lines are not counted (unlike `loc`, the raw file line count).", - "group": "loc", - "label": "Source", - "name": "Source lines", - "short": "SLOC", - "value_type": "int" - }, - "spaces": { - "description": "Unit count: the source file (1) plus each function / impl / trait / closure space. Feeds `cyclomatic`.", - "direction": "lower_better", - "group": "complexity", - "label": "Spaces", - "name": "Code units", - "short": "Spaces", - "value_type": "int" - }, - "span_sloc": { - "description": "Line span of the unit (end_row − start_row) — the size input the Maintainability Index (`mi` / `mi_sei`) is computed from.", - "direction": "lower_better", - "group": "maintainability", - "label": "Span", - "name": "Line span", - "short": "Span", - "value_type": "int" - }, - "time": { - "calc": "effort / 18", - "description": "Estimated implementation time, in seconds.", - "direction": "lower_better", - "formula": "effort ÷ 18", - "group": "halstead", - "label": "Time", - "name": "Coding time (s)", - "short": "H.time(s)", - "value_type": "float" - }, - "vocabulary": { - "calc": "eta1 + eta2", - "description": "Vocabulary — distinct operators + operands.", - "direction": "lower_better", - "formula": "eta1 + eta2", - "group": "halstead", - "label": "Vocabulary", - "name": "Distinct symbols", - "short": "H.vocab", - "value_type": "float" + { + "connections": [ + "in" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/ISP.md", + "id": "ISP", + "label": "ISP", + "prompt": "Clients should not be forced to depend on methods they do not use. Prefer several\nsmall, focused interfaces over one wide interface.\n\nIdentify interfaces in the modules below that are wider than their consumers need.\nPropose how to split them into narrower interfaces so each consumer depends only on\nwhat it actually uses.", + "sort_metric": "items", + "title": "ISP — Interface Segregation Principle" }, - "volume": { - "calc": "length * Math.log2(vocabulary)", - "description": "Algorithm size in bits, from distinct operators and operands.", - "direction": "lower_better", - "formula": "length × log₂(vocabulary)", - "group": "halstead", - "label": "Volume", - "name": "Code volume", - "short": "H.vol", - "value_type": "float" - } - }, - "node_kinds": { - "external": { - "external": true, - "fill": "#f6e2c0", - "label": "Library", - "plural": "Libraries", - "stroke": "#b3801f" + { + "connections": [ + "common", + "out" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/DIP.md", + "id": "DIP", + "label": "DIP", + "prompt": "High-level modules should not depend on low-level modules; both should depend on\nabstractions, and abstractions should not depend on details.\n\nFind places in the modules below where a high-level module depends directly on a\nconcrete low-level type. Propose an abstraction (interface) to invert each such\ndependency, and specify where the concrete implementation should be wired in.", + "sort_metric": "fan_out", + "title": "DIP — Dependency Inversion Principle" }, - "file": { - "fill": "#dbe9f4", - "label": "File", - "plural": "Files", - "stroke": "#4d6f9c" - } - }, - "nodes": [ { - "external": true, - "id": "ext:vector", - "kind": "external", - "name": "vector" + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/DRY.md", + "id": "DRY", + "label": "DRY", + "prompt": "Every piece of knowledge must have a single authoritative representation.\nDRY is about knowledge duplication, not just code duplication.\n\nIdentify concepts, rules, or policies that are duplicated across the modules\nbelow. For each duplication, propose a canonical location and the refactoring\nneeded to consolidate it.", + "sort_metric": "sloc", + "title": "DRY — Don't Repeat Yourself" }, { - "args": 1, - "blank": 1, - "branches": 3, - "bugs": 0.101, - "cloc": 1, - "closures": 1, - "cognitive": 4, - "cyclomatic": 6, - "effort": 5278.475, - "eta1": 16, - "eta2": 12, - "exits": 2, - "fan_out": 1, - "fan_out_external": 1, - "id": "{target}/main.cpp", - "kind": "file", - "length": 61, - "lloc": 10, - "loc": 14, - "mi": 97.325, - "mi_sei": 85.437, - "n1": 34, - "n2": 27, - "name": "main.cpp", - "sloc": 12, - "spaces": 3, - "span_sloc": 14, - "time": 293.248, - "vocabulary": 28, - "volume": 293.248 + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/KISS.md", + "id": "KISS", + "label": "KISS", + "prompt": "When two designs solve the same problem, prefer the simpler one — fewer\nabstractions, fewer indirection layers, fewer moving parts.\n\nIdentify over-engineered or needlessly complex constructs in the modules below.\nFor each, describe the simpler alternative and estimate the risk of simplifying.", + "sort_metric": "cognitive", + "title": "KISS — Keep It Simple" }, { - "args": 1, - "blank": 1, - "bugs": 0.0234, - "cloc": 1, - "cyclomatic": 2, - "effort": 589.382, - "eta1": 8, - "eta2": 7, - "exits": 1, - "fan_out": 1, - "id": "{target}/mathx.cpp", - "kind": "file", - "length": 22, - "lloc": 3, - "loc": 7, - "mi": 115.856, - "mi_sei": 119.28, - "n1": 10, - "n2": 12, - "name": "mathx.cpp", - "sloc": 5, - "spaces": 2, - "span_sloc": 7, - "time": 32.743, - "vocabulary": 15, - "volume": 85.951 + "connections": [ + "common", + "out" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/LoD.md", + "id": "LoD", + "label": "LoD", + "prompt": "A method should only call methods on: itself, its direct fields,\nits parameters, and objects it constructs locally.\nAvoid `x.foo().bar().baz()` chains that traverse object graphs.\n\nIdentify method chains or deep field traversals in the modules below that\nviolate LoD. For each, propose a narrow accessor or a facade that exposes only\nwhat the caller needs, reducing coupling.", + "sort_metric": "fan_out", + "title": "Law of Demeter — Principle of Least Knowledge" }, { - "blank": 3, - "bugs": 0.0109, - "cloc": 1, - "effort": 188.884, - "eta1": 6, - "eta2": 5, - "fan_in": 2, - "fan_out": 1, - "hk": 20, - "id": "{target}/mathx.hpp", - "kind": "file", - "length": 13, - "lloc": 3, - "loc": 9, - "mi": 115.383, - "mi_sei": 115.551, - "n1": 6, - "n2": 7, - "name": "mathx.hpp", - "sloc": 5, - "spaces": 1, - "span_sloc": 9, - "time": 10.493, - "vocabulary": 11, - "volume": 44.972 + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/MISU.md", + "id": "MISU", + "label": "MISU", + "prompt": "Move correctness from runtime checks into the type system, so invalid states\ncannot be constructed and fail at compile time rather than at runtime.\n\nIdentify data structures or function signatures in the modules below where invalid\nstates are representable at runtime. For each, propose a type-level encoding\n(sum type / enum, newtype, typestate) that makes the invalid state unrepresentable\nby construction.", + "sort_metric": "cyclomatic", + "title": "MISU — Make Invalid States Unrepresentable" }, { - "args": 2, - "blank": 1, - "branches": 1, - "bugs": 0.0274, - "cloc": 1, - "cognitive": 1, - "cyclomatic": 3, - "effort": 748.968, - "eta1": 8, - "eta2": 5, - "exits": 2, - "fan_out": 1, - "id": "{target}/util.cpp", - "kind": "file", - "length": 23, - "lloc": 4, - "loc": 9, - "mi": 111.606, - "mi_sei": 110.306, - "n1": 12, - "n2": 11, - "name": "util.cpp", - "sloc": 7, - "spaces": 2, - "span_sloc": 9, - "time": 41.609, - "vocabulary": 13, - "volume": 85.11 + "connections": [ + "common" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/CoI.md", + "id": "CoI", + "label": "CoI", + "prompt": "Build behaviour by composing small, focused pieces rather than through deep\ninheritance hierarchies.\n\nIdentify large types that accumulate behaviour in the modules below. Propose how to\ndecompose them into smaller composable parts, and show how consumers would assemble\nthe behaviour they need.", + "sort_metric": "items", + "title": "CoI — Composition Over Inheritance" }, { - "blank": 2, - "bugs": 0.0126, - "cloc": 1, - "effort": 232.473, - "eta1": 6, - "eta2": 5, - "fan_in": 2, - "id": "{target}/util.hpp", - "kind": "file", - "length": 14, - "lloc": 2, - "loc": 7, - "mi": 119.069, - "mi_sei": 123.814, - "n1": 6, - "n2": 8, - "name": "util.hpp", - "sloc": 4, - "spaces": 1, - "span_sloc": 7, - "time": 12.915, - "vocabulary": 11, - "volume": 48.432 + "connections": [ + "out" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/YAGNI.md", + "id": "YAGNI", + "label": "YAGNI", + "prompt": "Build for the problem you have now, not one you imagine you might have later.\nDon't add an abstraction, a generic parameter, or a public API for a hypothetical\nfuture use.\n\nIdentify abstractions, generics, or public APIs in the modules below that were\nadded speculatively. For each, assess whether multiple real callers use it today,\nand propose simplification if not.", + "sort_metric": "sloc", + "title": "YAGNI — You Aren't Gonna Need It" } ], - "stats": { - "blank": 1.6, - "bugs": 0.035, - "cloc": 1, - "cognitive": 2.5, - "cyclomatic": 3.666, - "effort": 1407.636, - "fan_in": 2, - "fan_out": 1, - "hk": 20, - "length": 26.6, - "mi": 111.847, - "mi_sei": 110.877, - "sloc": 6.6, - "time": 78.201, - "vocabulary": 15.6, - "volume": 111.542 - }, - "ui": { - "card": [ - "hk", - "sloc" - ], - "columns": [ - "kind", - "sloc", - "hk", - "fan_in", - "fan_out", - "volume", - "bugs", - "effort", - "time", - "length", - "vocabulary", - "cyclomatic", - "cognitive", - "mi", - "mi_sei", - "lloc", - "cloc", - "blank" - ], - "default_sort": "hk", - "filter": [], - "size": [ - "sloc", - "hk" - ], - "sort": [ - "sloc", - "hk", - "fan_in", - "fan_out", - "volume", - "bugs", - "effort", - "time", - "length", - "vocabulary", - "cyclomatic", - "cognitive", - "mi", - "mi_sei", - "lloc", - "cloc", - "blank" - ], - "summary": [ - "sloc", - "hk", - "fan_in", - "fan_out", - "volume", - "bugs", - "effort", - "time", - "length", - "vocabulary", - "cyclomatic", - "cognitive", - "mi", - "mi_sei", - "lloc", - "cloc", - "blank" + "prompt": { + "cycle_note": "This is **one** dependency cycle; every module in it is listed below so the whole loop is visible. Fix one cycle at a time — `--top 2`+ lists several separate cycles at once and obscures how each one connects.", + "doc_note": "**First, before reading the source**, run `code-ranker docs {id}` — it prints the full principle and, for your language, the usual cause of this exact violation and the smallest correct fix, often with a worked example and how to confirm it. Read it first: it normally names the remedy outright, so you apply it instead of re-deriving the mechanism from the code.", + "focus": "**Focus the research and report primarily on the modules below.**", + "intro": "I want to apply this to some modules in my system.", + "task": [ + "- Prepare a precise, detailed estimate and a report of where the modules below violate it.", + "- If you find more serious violations elsewhere during research, mention them in the report too.", + "- Show a summary of the report in chat.", + "- If any violation is found, suggest saving the report to a file as a plan for a detailed review, named `.code-ranker/-{id}.md`." ] } } }, - "plugin": "cpp", - "principles": [ - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/CPX.md", - "id": "CPX", - "label": "CPX", - "prompt": "These modules are too complex and I want to reduce their complexity.\nReduce it by splitting large units into smaller single-responsibility ones,\nextracting repeated patterns into shared helpers, flattening deeply nested\ncontrol flow, and breaking large functions into focused helpers.", - "sort_metric": "cognitive", - "title": "CPX — Reduce Complexity" - }, - { - "connections": [ - "common" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/ADP.md", - "id": "ADP", - "label": "ADP", - "prompt": "The dependency graph between modules must form a DAG. When module A depends\non module B, no chain of dependencies should bring B back to A.\n\nIdentify any cycles in the modules below. For each cycle, propose a concrete\nrefactoring (extract a shared abstraction, invert a dependency, split a module)\nthat makes the graph acyclic without breaking existing functionality.\n\nWhen splitting a module to break a cycle, the new structure should:\n- Preserve existing API contracts\n- Minimise coupling in the new structure\n- Follow the Single Responsibility Principle\n- Not introduce new dependency cycles", - "sort_metric": "cycle", - "title": "ADP — Acyclic Dependencies Principle" - }, - { - "connections": [ - "in", - "out" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/SRP.md", - "id": "SRP", - "label": "SRP", - "prompt": "A module should have one reason to change — it should serve one actor\nand encapsulate one coherent set of decisions.\n\nFor each module below, identify whether it has more than one responsibility.\nPropose how to split responsibilities so each module changes for only one reason,\nand specify the new module boundaries.", - "sort_metric": "sloc", - "title": "SRP — Single Responsibility Principle" - }, - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/OCP.md", - "id": "OCP", - "label": "OCP", - "prompt": "A module should be open for extension but closed for modification: new behaviour\nshould be addable without editing existing, working code.\n\nFor each module below, identify extension points that currently require editing\nexisting code (e.g. growing match/switch/if-else chains). Propose an extension\nmechanism (polymorphism, strategy, plug-in registration) so new cases can be added\nwithout modifying these modules.", - "sort_metric": "cyclomatic", - "title": "OCP — Open/Closed Principle" - }, - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/LSP.md", - "id": "LSP", - "label": "LSP", - "prompt": "Every implementation of an interface must honour its full contract — return-value\ninvariants, error/exception behaviour, side effects, and resource ownership — not\njust the method signatures. A subtype must be substitutable for its base without\nsurprising callers.\n\nIdentify the interface implementations in the modules below. For each, check it can\nreplace any other implementation of the same interface without breaking callers.\nFlag violations and propose fixes.", - "sort_metric": "hk", - "title": "LSP — Liskov Substitution Principle" - }, - { - "connections": [ - "in" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/ISP.md", - "id": "ISP", - "label": "ISP", - "prompt": "Clients should not be forced to depend on methods they do not use. Prefer several\nsmall, focused interfaces over one wide interface.\n\nIdentify interfaces in the modules below that are wider than their consumers need.\nPropose how to split them into narrower interfaces so each consumer depends only on\nwhat it actually uses.", - "sort_metric": "items", - "title": "ISP — Interface Segregation Principle" - }, - { - "connections": [ - "common", - "out" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/DIP.md", - "id": "DIP", - "label": "DIP", - "prompt": "High-level modules should not depend on low-level modules; both should depend on\nabstractions, and abstractions should not depend on details.\n\nFind places in the modules below where a high-level module depends directly on a\nconcrete low-level type. Propose an abstraction (interface) to invert each such\ndependency, and specify where the concrete implementation should be wired in.", - "sort_metric": "fan_out", - "title": "DIP — Dependency Inversion Principle" - }, - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/DRY.md", - "id": "DRY", - "label": "DRY", - "prompt": "Every piece of knowledge must have a single authoritative representation.\nDRY is about knowledge duplication, not just code duplication.\n\nIdentify concepts, rules, or policies that are duplicated across the modules\nbelow. For each duplication, propose a canonical location and the refactoring\nneeded to consolidate it.", - "sort_metric": "sloc", - "title": "DRY — Don't Repeat Yourself" - }, - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/KISS.md", - "id": "KISS", - "label": "KISS", - "prompt": "When two designs solve the same problem, prefer the simpler one — fewer\nabstractions, fewer indirection layers, fewer moving parts.\n\nIdentify over-engineered or needlessly complex constructs in the modules below.\nFor each, describe the simpler alternative and estimate the risk of simplifying.", - "sort_metric": "cognitive", - "title": "KISS — Keep It Simple" - }, - { - "connections": [ - "common", - "out" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/LoD.md", - "id": "LoD", - "label": "LoD", - "prompt": "A method should only call methods on: itself, its direct fields,\nits parameters, and objects it constructs locally.\nAvoid `x.foo().bar().baz()` chains that traverse object graphs.\n\nIdentify method chains or deep field traversals in the modules below that\nviolate LoD. For each, propose a narrow accessor or a facade that exposes only\nwhat the caller needs, reducing coupling.", - "sort_metric": "fan_out", - "title": "Law of Demeter — Principle of Least Knowledge" - }, - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/MISU.md", - "id": "MISU", - "label": "MISU", - "prompt": "Move correctness from runtime checks into the type system, so invalid states\ncannot be constructed and fail at compile time rather than at runtime.\n\nIdentify data structures or function signatures in the modules below where invalid\nstates are representable at runtime. For each, propose a type-level encoding\n(sum type / enum, newtype, typestate) that makes the invalid state unrepresentable\nby construction.", - "sort_metric": "cyclomatic", - "title": "MISU — Make Invalid States Unrepresentable" - }, - { - "connections": [ - "common" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/CoI.md", - "id": "CoI", - "label": "CoI", - "prompt": "Build behaviour by composing small, focused pieces rather than through deep\ninheritance hierarchies.\n\nIdentify large types that accumulate behaviour in the modules below. Propose how to\ndecompose them into smaller composable parts, and show how consumers would assemble\nthe behaviour they need.", - "sort_metric": "items", - "title": "CoI — Composition Over Inheritance" - }, - { - "connections": [ - "out" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/YAGNI.md", - "id": "YAGNI", - "label": "YAGNI", - "prompt": "Build for the problem you have now, not one you imagine you might have later.\nDon't add an abstraction, a generic parameter, or a public API for a hypothetical\nfuture use.\n\nIdentify abstractions, generics, or public APIs in the modules below that were\nadded speculatively. For each, assess whether multiple real callers use it today,\nand propose simplification if not.", - "sort_metric": "sloc", - "title": "YAGNI — You Aren't Gonna Need It" - } + "plugins": [ + "cpp" ], - "prompt": { - "cycle_note": "This is **one** dependency cycle; every module in it is listed below so the whole loop is visible. Fix one cycle at a time — `--top 2`+ lists several separate cycles at once and obscures how each one connects.", - "doc_note": "**First, before reading the source**, run `code-ranker docs {id}` — it prints the full principle and, for your language, the usual cause of this exact violation and the smallest correct fix, often with a worked example and how to confirm it. Read it first: it normally names the remedy outright, so you apply it instead of re-deriving the mechanism from the code.", - "focus": "**Focus the research and report primarily on the modules below.**", - "intro": "I want to apply this to some modules in my system.", - "task": [ - "- Prepare a precise, detailed estimate and a report of where the modules below violate it.", - "- If you find more serious violations elsewhere during research, mention them in the report too.", - "- Show a summary of the report in chat.", - "- If any violation is found, suggest saving the report to a file as a plan for a detailed review, named `.code-ranker/-{id}.md`." - ] - }, "roots": { "target": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/cpp/tests/sample" }, - "schema_version": "4.0", + "schema_version": "5.0", "target": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/cpp/tests/sample", "timings": [ { "detail": "6 nodes from 5 files", "ms": 0, - "stage": "cpp" + "stage": "cpp: parse" }, { "detail": "5 nodes annotated", "ms": 0, - "stage": "complexity" + "stage": "cpp: complexity" }, { "detail": "nodes=6 edges=5", "ms": 0, - "stage": "projection" + "stage": "cpp: projection" } ], "versions": { - "code-ranker": "4.0.0" + "code-ranker": "5.0.0" }, "workspace": "/home/user/code-ranker" } diff --git a/crates/code-ranker-plugins/src/languages/cpp/tests/sample/code-ranker.toml b/crates/code-ranker-plugins/src/languages/cpp/tests/sample/code-ranker.toml index cb76a532..dcb993c1 100644 --- a/crates/code-ranker-plugins/src/languages/cpp/tests/sample/code-ranker.toml +++ b/crates/code-ranker-plugins/src/languages/cpp/tests/sample/code-ranker.toml @@ -1,6 +1,6 @@ -version = "4.0" +version = "5.0" # Self-contained config for the code-ranker "cpp" sample fixture. -plugin = "cpp" - -[ignore] +[plugins] +enabled = ["cpp"] +[plugins.base.ignore] tests = false diff --git a/crates/code-ranker-plugins/src/languages/csharp/config.toml b/crates/code-ranker-plugins/src/languages/csharp/config.toml index c38a357f..40f1eb3c 100644 --- a/crates/code-ranker-plugins/src/languages/csharp/config.toml +++ b/crates/code-ranker-plugins/src/languages/csharp/config.toml @@ -12,6 +12,8 @@ doc_lang = "csharp" # no single universal manifest, so there are no `detect_markers`. extensions = ["cs"] skip_dirs = ["bin", "obj", "node_modules"] +# Short aliases accepted anywhere a language is named. Unique across languages. +aliases = ["cs", "c#"] test_dirs = ["test", "tests"] test_suffixes = ["Tests.cs", "Test.cs"] @@ -98,11 +100,5 @@ statement_kinds.named = [ [specs.eta1] description = "Distinct operators (η₁): the count of unique operator token kinds. C# counts arithmetic / logical / comparison / assignment operators (`+ - * / % ++ -- && || ! == != < > <= >= = += … ?? ?. =>`), bitwise (`& | ^ << >> ~`), and the keywords `class struct interface record enum namespace using new is as async await yield return throw try catch finally if else switch case for foreach while do break continue goto var const static public private protected internal virtual override abstract sealed readonly ref out params lock`." -[specs.n1] -description = "Total operators (N₁): every operator occurrence counted with repetition (the η₁ tokens, not deduplicated)." - [specs.eta2] description = "Distinct operands (η₂): the count of unique operand texts. C# counts identifiers, literals (integer, real, string, verbatim-string, char, boolean), `null`, and the predefined type names (`int` / `string` / `bool` / …)." - -[specs.n2] -description = "Total operands (N₂): every operand occurrence counted with repetition (the η₂ texts, not deduplicated)." diff --git a/crates/code-ranker-plugins/src/languages/csharp/mod.rs b/crates/code-ranker-plugins/src/languages/csharp/mod.rs index 610283b9..c5fe798d 100644 --- a/crates/code-ranker-plugins/src/languages/csharp/mod.rs +++ b/crates/code-ranker-plugins/src/languages/csharp/mod.rs @@ -40,16 +40,16 @@ impl LanguagePlugin for CsharpPlugin { "csharp" } - fn detect(&self, workspace: &Path, input: &PluginInput) -> bool { + fn detect(&self, _cfg: &toml::Table, workspace: &Path, input: &PluginInput) -> bool { structure::detect(workspace, &crate::walk::ignore_from(input)) } - fn levels(&self) -> Vec { + fn levels(&self, cfg: &toml::Table) -> Vec { vec![ Level { name: "files".into(), - edge_kinds: crate::config::edge_kinds(&CONFIG), - node_attributes: crate::config::node_attributes(&CONFIG), + edge_kinds: crate::config::edge_kinds(cfg), + node_attributes: crate::config::node_attributes(cfg), edge_attributes: BTreeMap::new(), attribute_groups: BTreeMap::new(), node_kinds: default_node_kinds(), @@ -62,14 +62,14 @@ impl LanguagePlugin for CsharpPlugin { node_attributes: BTreeMap::new(), edge_attributes: BTreeMap::new(), attribute_groups: BTreeMap::new(), - node_kinds: crate::config::node_kinds(&CONFIG), + node_kinds: crate::config::node_kinds(cfg), cycle_kinds: default_cycle_kinds(), grouping: None, }, ] } - fn analyze(&self, workspace: &Path, input: &PluginInput) -> Result { + fn analyze(&self, _cfg: &toml::Table, workspace: &Path, input: &PluginInput) -> Result { structure::analyze( workspace, input.ignore_tests, @@ -77,27 +77,31 @@ impl LanguagePlugin for CsharpPlugin { ) } - fn metrics(&self, graph: &Graph) -> Vec<(String, MetricInputs)> { + fn metrics(&self, _cfg: &toml::Table, graph: &Graph) -> Vec<(String, MetricInputs)> { file_metrics(graph) } - fn function_units(&self, graph: &Graph) -> Vec<(Node, MetricInputs)> { + fn function_units(&self, _cfg: &toml::Table, graph: &Graph) -> Vec<(Node, MetricInputs)> { function_nodes(graph) } - fn principles(&self, _input: &PluginInput) -> Vec { - crate::config::resolved_principles(&CONFIG) + fn principles(&self, cfg: &toml::Table, _input: &PluginInput) -> Vec { + crate::config::resolved_principles(cfg) } - fn report_overrides(&self) -> code_ranker_plugin_api::report::ReportOverride { - code_ranker_plugin_api::list_override::report_override(&CONFIG) + fn report_overrides( + &self, + cfg: &toml::Table, + ) -> code_ranker_plugin_api::report::ReportOverride { + code_ranker_plugin_api::list_override::report_override(cfg) } fn metric_specs( &self, + cfg: &toml::Table, defaults: BTreeMap, ) -> BTreeMap { - crate::config::apply_spec_overrides(defaults, &CONFIG) + crate::config::apply_spec_overrides(defaults, cfg) } } diff --git a/crates/code-ranker-plugins/src/languages/csharp/tests/mod_rs.rs b/crates/code-ranker-plugins/src/languages/csharp/tests/mod_rs.rs index 6042bd59..910ae9ba 100644 --- a/crates/code-ranker-plugins/src/languages/csharp/tests/mod_rs.rs +++ b/crates/code-ranker-plugins/src/languages/csharp/tests/mod_rs.rs @@ -6,9 +6,10 @@ use super::*; fn detects_by_cs_source_presence() { let d = tempfile::tempdir().unwrap(); let p = CsharpPlugin; - assert!(!p.detect(d.path(), &PluginInput::default())); + let cfg = p.config(); + assert!(!p.detect(&cfg, d.path(), &PluginInput::default())); std::fs::write(d.path().join("A.cs"), "class A {}\n").unwrap(); - assert!(p.detect(d.path(), &PluginInput::default())); + assert!(p.detect(&cfg, d.path(), &PluginInput::default())); assert_eq!(p.name(), "csharp"); } @@ -21,9 +22,14 @@ fn metrics_and_function_units_over_a_temp_project() { ) .unwrap(); let p = CsharpPlugin; - let g = p.analyze(d.path(), &PluginInput::default()).unwrap(); - assert!(!p.metrics(&g).is_empty()); - assert!(p.function_units(&g).iter().any(|(n, _)| n.name == "Add")); + let cfg = p.config(); + let g = p.analyze(&cfg, d.path(), &PluginInput::default()).unwrap(); + assert!(!p.metrics(&cfg, &g).is_empty()); + assert!( + p.function_units(&cfg, &g) + .iter() + .any(|(n, _)| n.name == "Add") + ); } #[test] @@ -45,6 +51,7 @@ fn metrics_skip_non_file_and_unreadable_nodes() { ], edges: vec![], }; - assert!(CsharpPlugin.metrics(&g).is_empty()); - assert!(CsharpPlugin.function_units(&g).is_empty()); + let cfg = CsharpPlugin.config(); + assert!(CsharpPlugin.metrics(&cfg, &g).is_empty()); + assert!(CsharpPlugin.function_units(&cfg, &g).is_empty()); } diff --git a/crates/code-ranker-plugins/src/languages/csharp/tests/sample/code-ranker-check.sarif b/crates/code-ranker-plugins/src/languages/csharp/tests/sample/code-ranker-check.sarif index 03beb451..b2abc2cf 100644 --- a/crates/code-ranker-plugins/src/languages/csharp/tests/sample/code-ranker-check.sarif +++ b/crates/code-ranker-plugins/src/languages/csharp/tests/sample/code-ranker-check.sarif @@ -8,7 +8,7 @@ "informationUri": "https://github.com/ffedoroff/code-ranker", "name": "code-ranker", "rules": [], - "version": "3.0.0-alpha.1" + "version": "4.0.0" } } } diff --git a/crates/code-ranker-plugins/src/languages/csharp/tests/sample/code-ranker-report.json b/crates/code-ranker-plugins/src/languages/csharp/tests/sample/code-ranker-report.json index b5b4e4f1..a656c538 100644 --- a/crates/code-ranker-plugins/src/languages/csharp/tests/sample/code-ranker-report.json +++ b/crates/code-ranker-plugins/src/languages/csharp/tests/sample/code-ranker-report.json @@ -1,5 +1,5 @@ { - "command": "code-ranker report crates/code-ranker-plugins/src/languages/csharp/tests/sample --config crates/code-ranker-plugins/src/languages/csharp/tests/sample/code-ranker.toml --output.json.path=crates/code-ranker-plugins/src/languages/csharp/tests/sample/code-ranker-report.json --output.mode quiet", + "command": "code-ranker report crates/code-ranker-plugins/src/languages/csharp/tests/sample --config crates/code-ranker-plugins/src/languages/csharp/tests/sample/code-ranker.toml --output.json.path=crates/code-ranker-plugins/src/languages/csharp/tests/sample/code-ranker-report.json", "config_file": "crates/code-ranker-plugins/src/languages/csharp/tests/sample/code-ranker.toml", "generated_at": "1970-01-01T00:00:00Z", "git": { @@ -8,711 +8,717 @@ "dirty_files": 0, "origin": "git@example.com:org/repo.git" }, - "graphs": { - "files": { - "attribute_groups": { - "complexity": { - "description": "per-function branching, nesting & size", - "label": "Complexity" - }, - "coupling": { - "description": "how tightly modules depend on each other", - "label": "Coupling" - }, - "halstead": { - "description": "operator/operand vocabulary & derived effort", - "label": "Halstead" - }, - "loc": { - "description": "physical line counts", - "label": "Lines of Code" - }, - "maintainability": { - "description": "composite score", - "label": "Maintainability" - } - }, - "cycle_kinds": {}, - "cycles": [], - "edge_attributes": {}, - "edge_kinds": { - "uses": { - "description": "Import dependency — this file imports from the other.", - "flow": true, - "label": "uses" + "languages": { + "csharp": { + "graphs": { + "files": { + "attribute_groups": { + "complexity": { + "description": "per-function branching, nesting & size", + "label": "Complexity" + }, + "coupling": { + "description": "how tightly modules depend on each other", + "label": "Coupling" + }, + "halstead": { + "description": "operator/operand vocabulary & derived effort", + "label": "Halstead" + }, + "loc": { + "description": "physical line counts", + "label": "Lines of Code" + }, + "maintainability": { + "description": "composite score", + "label": "Maintainability" + } + }, + "cycle_kinds": {}, + "cycles": [], + "edge_attributes": {}, + "edge_kinds": { + "uses": { + "description": "Import dependency — this file imports from the other.", + "flow": true, + "label": "uses" + } + }, + "edges": [ + { + "kind": "uses", + "line": 2, + "source": "{target}/Main.cs", + "target": "ext:System" + }, + { + "kind": "uses", + "line": 3, + "source": "{target}/Main.cs", + "target": "{target}/Mathx.cs" + }, + { + "kind": "uses", + "line": 2, + "source": "{target}/Mathx.cs", + "target": "{target}/Util.cs" + } + ], + "node_attributes": { + "args": { + "description": "Number of function / closure arguments.", + "direction": "lower_better", + "group": "complexity", + "label": "Args", + "name": "Arguments", + "short": "Args", + "value_type": "int" + }, + "blank": { + "description": "Empty or whitespace-only lines.", + "group": "loc", + "label": "Blank", + "name": "Blank lines", + "short": "Blank", + "value_type": "int" + }, + "branches": { + "description": "Decision points: if / for / while / loop / match arm / try / && / ||. Feeds `cyclomatic`.", + "direction": "lower_better", + "group": "complexity", + "label": "Branches", + "name": "Decision points", + "short": "Branches", + "value_type": "int" + }, + "bugs": { + "calc": "effort ** (2/3) / 3000", + "description": "Estimated delivered bugs — a rough predictor of defect density.", + "direction": "lower_better", + "formula": "effort^⅔ ÷ 3000", + "group": "halstead", + "label": "Bugs", + "name": "Estimated bugs", + "short": "H.bugs", + "value_type": "float" + }, + "cloc": { + "description": "Comment-only lines (inline comments on code lines are not counted).", + "group": "loc", + "label": "Comments", + "name": "Comment lines", + "short": "Comments", + "value_type": "int" + }, + "closures": { + "description": "Number of closures defined in the unit.", + "direction": "lower_better", + "group": "complexity", + "label": "Closures", + "name": "Closures defined", + "short": "Closures", + "value_type": "int" + }, + "cognitive": { + "description": "How hard the code is for a human to follow — not just how many paths it has.
Like `cyclomatic` it adds +1 for each break in linear flow (`if`, `else`, `match`, loops, `catch`, chained `&&` / `||`), but it also adds an extra +1 for every level of nesting: an `if` inside a loop inside an `if` costs far more than three flat `if`s.
That nesting penalty is the point — deeply indented logic is what actually strains a reader, so a high `cognitive` next to a modest `cyclomatic` flags tangled, hard-to-read code.
Summed across every function in the file.", + "direction": "lower_better", + "group": "complexity", + "label": "Cognitive", + "name": "Cognitive complexity", + "short": "Cognitive", + "value_type": "int" + }, + "cyclomatic": { + "calc": "spaces + branches", + "description": "Number of independent paths through the code — roughly the minimum number of test cases needed to cover every branch.
A function starts at 1 and gains +1 per decision point: each `if` / `else if`, every `match` / `switch` arm, every loop, and each `&&` / `||` in a condition.
Summed across every function in the file, so it grows with both size and branching — the file's total branching burden.
Counts paths only, ignoring how deeply they nest. For a readability-weighted view see `cognitive`.", + "direction": "lower_better", + "formula": "spaces + branches", + "group": "complexity", + "label": "Cyclomatic", + "name": "Cyclomatic complexity", + "omit_at": 1.0, + "short": "Cyclomatic", + "value_type": "int" + }, + "effort": { + "calc": "(eta1 / 2) * (n2 / eta2) * volume", + "description": "Mental effort to implement the algorithm.", + "direction": "lower_better", + "formula": "(eta1 ÷ 2) × (n2 ÷ eta2) × volume", + "group": "halstead", + "label": "Effort", + "name": "Implementation effort", + "short": "H.effort", + "value_type": "float" + }, + "eta1": { + "description": "Distinct operators (η₁): the count of unique operator token kinds. C# counts arithmetic / logical / comparison / assignment operators (`+ - * / % ++ -- && || ! == != < > <= >= = += … ?? ?. =>`), bitwise (`& | ^ << >> ~`), and the keywords `class struct interface record enum namespace using new is as async await yield return throw try catch finally if else switch case for foreach while do break continue goto var const static public private protected internal virtual override abstract sealed readonly ref out params lock`.", + "direction": "lower_better", + "group": "halstead", + "label": "η₁", + "name": "Unique operators", + "short": "η₁", + "value_type": "int" + }, + "eta2": { + "description": "Distinct operands (η₂): the count of unique operand texts. C# counts identifiers, literals (integer, real, string, verbatim-string, char, boolean), `null`, and the predefined type names (`int` / `string` / `bool` / …).", + "direction": "lower_better", + "group": "halstead", + "label": "η₂", + "name": "Unique operands", + "short": "η₂", + "value_type": "int" + }, + "exits": { + "description": "Number of exit points (return/throw) in the unit.", + "direction": "lower_better", + "group": "complexity", + "label": "Exits", + "name": "Exit points", + "short": "Exits", + "value_type": "int" + }, + "external": { + "label": "External", + "value_type": "bool" + }, + "fan_in": { + "description": "Many other units depend on this one, making it risky to change and a single point of failure — though some hubs (shared types) carry high fan-in legitimately.", + "group": "coupling", + "label": "Fan-in", + "name": "Incoming dependencies", + "short": "Fan-in", + "value_type": "int" + }, + "fan_out": { + "description": "This unit depends on many others, so it breaks when any of them change and is hard to test in isolation.", + "group": "coupling", + "label": "Fan-out", + "name": "Outgoing dependencies", + "short": "Fan-out", + "value_type": "int" + }, + "fan_out_external": { + "description": "Number of distinct external libraries this node depends on.", + "group": "coupling", + "label": "Fan-out (external)", + "name": "External dependencies", + "short": "Fan-out (external)", + "value_type": "int" + }, + "hk": { + "abbreviate": true, + "calc": "sloc * (fan_in * fan_out) ** 2", + "description": "Henry-Kafura information-flow complexity: a module that is both a busy crossroads (high fan-in × fan-out) and large — the most expensive place in the codebase to change.", + "direction": "lower_better", + "formula": "sloc × (fan_in × fan_out)²", + "group": "coupling", + "label": "HK", + "name": "God-object risk", + "short": "HK", + "value_type": "float" + }, + "length": { + "calc": "n1 + n2", + "description": "Program length — total operator + operand occurrences.", + "direction": "lower_better", + "formula": "n1 + n2", + "group": "halstead", + "label": "Length", + "name": "Total tokens", + "short": "H.len", + "value_type": "float" + }, + "lloc": { + "description": "Logical lines — counts statements, not physical lines.", + "group": "loc", + "label": "Logical", + "name": "Logical lines", + "short": "Logical", + "value_type": "int" + }, + "loc": { + "description": "Raw file line count, including blank and comment lines (unlike `sloc`). Large files tend to hold several responsibilities and are harder to review, test, and reuse.", + "group": "loc", + "label": "Lines", + "name": "Total lines", + "remediation": "Split by responsibility into smaller units, extract helpers, and separate data definitions from behavior. For an average breach, break up the largest units first (--top).", + "value_type": "int" + }, + "mi": { + "calc": "171 - 5.2*Math.log(volume) - 0.23*cyclomatic - 16.2*Math.log(span_sloc)", + "description": "Maintainability Index (0–100, higher is more maintainable). Derived from Halstead volume, cyclomatic complexity, and SLOC.", + "direction": "higher_better", + "formula": "171 − 5.2·ln(volume) − 0.23·cyclomatic − 16.2·ln(span_sloc)", + "group": "maintainability", + "label": "MI", + "name": "Maintainability index", + "short": "MI", + "value_type": "float" + }, + "mi_sei": { + "calc": "171 - 5.2*Math.log2(volume) - 0.23*cyclomatic - 16.2*Math.log2(span_sloc) + 50*Math.sin(Math.sqrt(cloc / span_sloc * 2.4))", + "description": "SEI variant of the Maintainability Index — adds a bonus for comment density.", + "direction": "higher_better", + "formula": "171 − 5.2·log₂(volume) − 0.23·cyclomatic − 16.2·log₂(span_sloc) + 50·sin(√(cloc ÷ span_sloc × 2.4))", + "group": "maintainability", + "label": "MI (SEI)", + "name": "Maintainability (SEI)", + "short": "MI SEI", + "value_type": "float" + }, + "n1": { + "description": "Total operators (N₁): every operator occurrence counted with repetition (the η₁ tokens, not deduplicated).", + "direction": "lower_better", + "group": "halstead", + "label": "N₁", + "name": "Total operators", + "short": "N₁", + "value_type": "int" + }, + "n2": { + "description": "Total operands (N₂): every operand occurrence counted with repetition (the η₂ texts, not deduplicated).", + "direction": "lower_better", + "group": "halstead", + "label": "N₂", + "name": "Total operands", + "short": "N₂", + "value_type": "int" + }, + "sloc": { + "description": "Source lines of code — lines with at least one non-whitespace, non-comment character. Blank and comment-only lines are not counted (unlike `loc`, the raw file line count).", + "group": "loc", + "label": "Source", + "name": "Source lines", + "short": "SLOC", + "value_type": "int" + }, + "spaces": { + "description": "Unit count: the source file (1) plus each function / impl / trait / closure space. Feeds `cyclomatic`.", + "direction": "lower_better", + "group": "complexity", + "label": "Spaces", + "name": "Code units", + "short": "Spaces", + "value_type": "int" + }, + "span_sloc": { + "description": "Line span of the unit (end_row − start_row) — the size input the Maintainability Index (`mi` / `mi_sei`) is computed from.", + "direction": "lower_better", + "group": "maintainability", + "label": "Span", + "name": "Line span", + "short": "Span", + "value_type": "int" + }, + "time": { + "calc": "effort / 18", + "description": "Estimated implementation time, in seconds.", + "direction": "lower_better", + "formula": "effort ÷ 18", + "group": "halstead", + "label": "Time", + "name": "Coding time (s)", + "short": "H.time(s)", + "value_type": "float" + }, + "vocabulary": { + "calc": "eta1 + eta2", + "description": "Vocabulary — distinct operators + operands.", + "direction": "lower_better", + "formula": "eta1 + eta2", + "group": "halstead", + "label": "Vocabulary", + "name": "Distinct symbols", + "short": "H.vocab", + "value_type": "float" + }, + "volume": { + "calc": "length * Math.log2(vocabulary)", + "description": "Algorithm size in bits, from distinct operators and operands.", + "direction": "lower_better", + "formula": "length × log₂(vocabulary)", + "group": "halstead", + "label": "Volume", + "name": "Code volume", + "short": "H.vol", + "value_type": "float" + } + }, + "node_kinds": { + "external": { + "external": true, + "fill": "#f6e2c0", + "label": "Library", + "plural": "Libraries", + "stroke": "#b3801f" + }, + "file": { + "fill": "#dbe9f4", + "label": "File", + "plural": "Files", + "stroke": "#4d6f9c" + } + }, + "nodes": [ + { + "external": true, + "id": "ext:System", + "kind": "external", + "name": "System" + }, + { + "blank": 1, + "branches": 3, + "bugs": 0.14, + "cloc": 2, + "closures": 1, + "cognitive": 4, + "cyclomatic": 6, + "effort": 8684.873, + "eta1": 22, + "eta2": 19, + "exits": 1, + "fan_out": 1, + "fan_out_external": 1, + "id": "{target}/Main.cs", + "kind": "file", + "length": 80, + "lloc": 12, + "loc": 21, + "mi": 89.574, + "mi_sei": 77.665, + "n1": 45, + "n2": 35, + "name": "Main.cs", + "sloc": 17, + "spaces": 3, + "span_sloc": 20, + "time": 482.492, + "vocabulary": 41, + "volume": 428.604 + }, + { + "args": 1, + "blank": 1, + "bugs": 0.0492, + "cloc": 2, + "cyclomatic": 2, + "effort": 1796.263, + "eta1": 13, + "eta2": 11, + "exits": 1, + "fan_in": 1, + "fan_out": 1, + "hk": 9, + "id": "{target}/Mathx.cs", + "kind": "file", + "length": 39, + "lloc": 6, + "loc": 13, + "mi": 103.315, + "mi_sei": 103.111, + "n1": 22, + "n2": 17, + "name": "Mathx.cs", + "sloc": 9, + "spaces": 2, + "span_sloc": 12, + "time": 99.792, + "vocabulary": 24, + "volume": 178.813 + }, + { + "args": 2, + "branches": 1, + "bugs": 0.0448, + "cloc": 2, + "cognitive": 1, + "cyclomatic": 3, + "effort": 1562.023, + "eta1": 12, + "eta2": 7, + "exits": 2, + "fan_in": 1, + "id": "{target}/Util.cs", + "kind": "file", + "length": 33, + "lloc": 6, + "loc": 13, + "mi": 104.351, + "mi_sei": 104.707, + "n1": 20, + "n2": 13, + "name": "Util.cs", + "sloc": 10, + "spaces": 2, + "span_sloc": 12, + "time": 86.779, + "vocabulary": 19, + "volume": 140.181 + } + ], + "stats": { + "blank": 1, + "bugs": 0.078, + "cloc": 2, + "cognitive": 2.5, + "cyclomatic": 3.666, + "effort": 4014.386, + "fan_in": 1, + "fan_out": 1, + "hk": 9, + "length": 50.666, + "mi": 99.08, + "mi_sei": 95.161, + "sloc": 12, + "time": 223.021, + "vocabulary": 28, + "volume": 249.199 + }, + "ui": { + "card": [ + "hk", + "sloc" + ], + "columns": [ + "kind", + "sloc", + "hk", + "fan_in", + "fan_out", + "volume", + "bugs", + "effort", + "time", + "length", + "vocabulary", + "cyclomatic", + "cognitive", + "mi", + "mi_sei", + "lloc", + "cloc", + "blank" + ], + "default_sort": "hk", + "filter": [], + "size": [ + "sloc", + "hk" + ], + "sort": [ + "sloc", + "hk", + "fan_in", + "fan_out", + "volume", + "bugs", + "effort", + "time", + "length", + "vocabulary", + "cyclomatic", + "cognitive", + "mi", + "mi_sei", + "lloc", + "cloc", + "blank" + ], + "summary": [ + "sloc", + "hk", + "fan_in", + "fan_out", + "volume", + "bugs", + "effort", + "time", + "length", + "vocabulary", + "cyclomatic", + "cognitive", + "mi", + "mi_sei", + "lloc", + "cloc", + "blank" + ] + } } }, - "edges": [ + "principles": [ { - "kind": "uses", - "line": 2, - "source": "{target}/Main.cs", - "target": "ext:System" + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/CPX.md", + "id": "CPX", + "label": "CPX", + "prompt": "These modules are too complex and I want to reduce their complexity.\nReduce it by splitting large units into smaller single-responsibility ones,\nextracting repeated patterns into shared helpers, flattening deeply nested\ncontrol flow, and breaking large functions into focused helpers.", + "sort_metric": "cognitive", + "title": "CPX — Reduce Complexity" }, { - "kind": "uses", - "line": 3, - "source": "{target}/Main.cs", - "target": "{target}/Mathx.cs" + "connections": [ + "common" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/ADP.md", + "id": "ADP", + "label": "ADP", + "prompt": "The dependency graph between modules must form a DAG. When module A depends\non module B, no chain of dependencies should bring B back to A.\n\nIdentify any cycles in the modules below. For each cycle, propose a concrete\nrefactoring (extract a shared abstraction, invert a dependency, split a module)\nthat makes the graph acyclic without breaking existing functionality.\n\nWhen splitting a module to break a cycle, the new structure should:\n- Preserve existing API contracts\n- Minimise coupling in the new structure\n- Follow the Single Responsibility Principle\n- Not introduce new dependency cycles", + "sort_metric": "cycle", + "title": "ADP — Acyclic Dependencies Principle" }, { - "kind": "uses", - "line": 2, - "source": "{target}/Mathx.cs", - "target": "{target}/Util.cs" - } - ], - "node_attributes": { - "args": { - "description": "Number of function / closure arguments.", - "direction": "lower_better", - "group": "complexity", - "label": "Args", - "name": "Arguments", - "short": "Args", - "value_type": "int" - }, - "blank": { - "description": "Empty or whitespace-only lines.", - "group": "loc", - "label": "Blank", - "name": "Blank lines", - "short": "Blank", - "value_type": "int" - }, - "branches": { - "description": "Decision points: if / for / while / loop / match arm / try / && / ||. Feeds `cyclomatic`.", - "direction": "lower_better", - "group": "complexity", - "label": "Branches", - "name": "Decision points", - "short": "Branches", - "value_type": "int" - }, - "bugs": { - "calc": "effort ** (2/3) / 3000", - "description": "Estimated delivered bugs — a rough predictor of defect density.", - "direction": "lower_better", - "formula": "effort^⅔ ÷ 3000", - "group": "halstead", - "label": "Bugs", - "name": "Estimated bugs", - "short": "H.bugs", - "value_type": "float" - }, - "cloc": { - "description": "Comment-only lines (inline comments on code lines are not counted).", - "group": "loc", - "label": "Comments", - "name": "Comment lines", - "short": "Comments", - "value_type": "int" - }, - "closures": { - "description": "Number of closures defined in the unit.", - "direction": "lower_better", - "group": "complexity", - "label": "Closures", - "name": "Closures defined", - "short": "Closures", - "value_type": "int" - }, - "cognitive": { - "description": "How hard the code is for a human to follow — not just how many paths it has.
Like `cyclomatic` it adds +1 for each break in linear flow (`if`, `else`, `match`, loops, `catch`, chained `&&` / `||`), but it also adds an extra +1 for every level of nesting: an `if` inside a loop inside an `if` costs far more than three flat `if`s.
That nesting penalty is the point — deeply indented logic is what actually strains a reader, so a high `cognitive` next to a modest `cyclomatic` flags tangled, hard-to-read code.
Summed across every function in the file.", - "direction": "lower_better", - "group": "complexity", - "label": "Cognitive", - "name": "Cognitive complexity", - "short": "Cognitive", - "value_type": "int" - }, - "cyclomatic": { - "calc": "spaces + branches", - "description": "Number of independent paths through the code — roughly the minimum number of test cases needed to cover every branch.
A function starts at 1 and gains +1 per decision point: each `if` / `else if`, every `match` / `switch` arm, every loop, and each `&&` / `||` in a condition.
Summed across every function in the file, so it grows with both size and branching — the file's total branching burden.
Counts paths only, ignoring how deeply they nest. For a readability-weighted view see `cognitive`.", - "direction": "lower_better", - "formula": "spaces + branches", - "group": "complexity", - "label": "Cyclomatic", - "name": "Cyclomatic complexity", - "omit_at": 1.0, - "short": "Cyclomatic", - "value_type": "int" - }, - "effort": { - "calc": "(eta1 / 2) * (n2 / eta2) * volume", - "description": "Mental effort to implement the algorithm.", - "direction": "lower_better", - "formula": "(eta1 ÷ 2) × (n2 ÷ eta2) × volume", - "group": "halstead", - "label": "Effort", - "name": "Implementation effort", - "short": "H.effort", - "value_type": "float" - }, - "eta1": { - "description": "Distinct operators (η₁): the count of unique operator token kinds. C# counts arithmetic / logical / comparison / assignment operators (`+ - * / % ++ -- && || ! == != < > <= >= = += … ?? ?. =>`), bitwise (`& | ^ << >> ~`), and the keywords `class struct interface record enum namespace using new is as async await yield return throw try catch finally if else switch case for foreach while do break continue goto var const static public private protected internal virtual override abstract sealed readonly ref out params lock`.", - "direction": "lower_better", - "group": "halstead", - "label": "η₁", - "name": "Unique operators", - "short": "η₁", - "value_type": "int" - }, - "eta2": { - "description": "Distinct operands (η₂): the count of unique operand texts. C# counts identifiers, literals (integer, real, string, verbatim-string, char, boolean), `null`, and the predefined type names (`int` / `string` / `bool` / …).", - "direction": "lower_better", - "group": "halstead", - "label": "η₂", - "name": "Unique operands", - "short": "η₂", - "value_type": "int" - }, - "exits": { - "description": "Number of exit points (return/throw) in the unit.", - "direction": "lower_better", - "group": "complexity", - "label": "Exits", - "name": "Exit points", - "short": "Exits", - "value_type": "int" + "connections": [ + "in", + "out" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/SRP.md", + "id": "SRP", + "label": "SRP", + "prompt": "A module should have one reason to change — it should serve one actor\nand encapsulate one coherent set of decisions.\n\nFor each module below, identify whether it has more than one responsibility.\nPropose how to split responsibilities so each module changes for only one reason,\nand specify the new module boundaries.", + "sort_metric": "sloc", + "title": "SRP — Single Responsibility Principle" }, - "external": { - "label": "External", - "value_type": "bool" - }, - "fan_in": { - "description": "Many other units depend on this one, making it risky to change and a single point of failure — though some hubs (shared types) carry high fan-in legitimately.", - "group": "coupling", - "label": "Fan-in", - "name": "Incoming dependencies", - "short": "Fan-in", - "value_type": "int" - }, - "fan_out": { - "description": "This unit depends on many others, so it breaks when any of them change and is hard to test in isolation.", - "group": "coupling", - "label": "Fan-out", - "name": "Outgoing dependencies", - "short": "Fan-out", - "value_type": "int" - }, - "fan_out_external": { - "description": "Number of distinct external libraries this node depends on.", - "group": "coupling", - "label": "Fan-out (external)", - "name": "External dependencies", - "short": "Fan-out (external)", - "value_type": "int" - }, - "hk": { - "abbreviate": true, - "calc": "sloc * (fan_in * fan_out) ** 2", - "description": "Henry-Kafura information-flow complexity: a module that is both a busy crossroads (high fan-in × fan-out) and large — the most expensive place in the codebase to change.", - "direction": "lower_better", - "formula": "sloc × (fan_in × fan_out)²", - "group": "coupling", - "label": "HK", - "name": "God-object risk", - "short": "HK", - "value_type": "float" - }, - "length": { - "calc": "n1 + n2", - "description": "Program length — total operator + operand occurrences.", - "direction": "lower_better", - "formula": "n1 + n2", - "group": "halstead", - "label": "Length", - "name": "Total tokens", - "short": "H.len", - "value_type": "float" - }, - "lloc": { - "description": "Logical lines — counts statements, not physical lines.", - "group": "loc", - "label": "Logical", - "name": "Logical lines", - "short": "Logical", - "value_type": "int" - }, - "loc": { - "description": "Raw file line count, including blank and comment lines (unlike `sloc`). Large files tend to hold several responsibilities and are harder to review, test, and reuse.", - "group": "loc", - "label": "Lines", - "name": "Total lines", - "remediation": "Split by responsibility into smaller units, extract helpers, and separate data definitions from behavior. For an average breach, break up the largest units first (--top).", - "value_type": "int" - }, - "mi": { - "calc": "171 - 5.2*Math.log(volume) - 0.23*cyclomatic - 16.2*Math.log(span_sloc)", - "description": "Maintainability Index (0–100, higher is more maintainable). Derived from Halstead volume, cyclomatic complexity, and SLOC.", - "direction": "higher_better", - "formula": "171 − 5.2·ln(volume) − 0.23·cyclomatic − 16.2·ln(span_sloc)", - "group": "maintainability", - "label": "MI", - "name": "Maintainability index", - "short": "MI", - "value_type": "float" - }, - "mi_sei": { - "calc": "171 - 5.2*Math.log2(volume) - 0.23*cyclomatic - 16.2*Math.log2(span_sloc) + 50*Math.sin(Math.sqrt(cloc / span_sloc * 2.4))", - "description": "SEI variant of the Maintainability Index — adds a bonus for comment density.", - "direction": "higher_better", - "formula": "171 − 5.2·log₂(volume) − 0.23·cyclomatic − 16.2·log₂(span_sloc) + 50·sin(√(cloc ÷ span_sloc × 2.4))", - "group": "maintainability", - "label": "MI (SEI)", - "name": "Maintainability (SEI)", - "short": "MI SEI", - "value_type": "float" - }, - "n1": { - "description": "Total operators (N₁): every operator occurrence counted with repetition (the η₁ tokens, not deduplicated).", - "direction": "lower_better", - "group": "halstead", - "label": "N₁", - "name": "Total operators", - "short": "N₁", - "value_type": "int" - }, - "n2": { - "description": "Total operands (N₂): every operand occurrence counted with repetition (the η₂ texts, not deduplicated).", - "direction": "lower_better", - "group": "halstead", - "label": "N₂", - "name": "Total operands", - "short": "N₂", - "value_type": "int" - }, - "sloc": { - "description": "Source lines of code — lines with at least one non-whitespace, non-comment character. Blank and comment-only lines are not counted (unlike `loc`, the raw file line count).", - "group": "loc", - "label": "Source", - "name": "Source lines", - "short": "SLOC", - "value_type": "int" + { + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/OCP.md", + "id": "OCP", + "label": "OCP", + "prompt": "A module should be open for extension but closed for modification: new behaviour\nshould be addable without editing existing, working code.\n\nFor each module below, identify extension points that currently require editing\nexisting code (e.g. growing match/switch/if-else chains). Propose an extension\nmechanism (polymorphism, strategy, plug-in registration) so new cases can be added\nwithout modifying these modules.", + "sort_metric": "cyclomatic", + "title": "OCP — Open/Closed Principle" }, - "spaces": { - "description": "Unit count: the source file (1) plus each function / impl / trait / closure space. Feeds `cyclomatic`.", - "direction": "lower_better", - "group": "complexity", - "label": "Spaces", - "name": "Code units", - "short": "Spaces", - "value_type": "int" + { + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/LSP.md", + "id": "LSP", + "label": "LSP", + "prompt": "Every implementation of an interface must honour its full contract — return-value\ninvariants, error/exception behaviour, side effects, and resource ownership — not\njust the method signatures. A subtype must be substitutable for its base without\nsurprising callers.\n\nIdentify the interface implementations in the modules below. For each, check it can\nreplace any other implementation of the same interface without breaking callers.\nFlag violations and propose fixes.", + "sort_metric": "hk", + "title": "LSP — Liskov Substitution Principle" }, - "span_sloc": { - "description": "Line span of the unit (end_row − start_row) — the size input the Maintainability Index (`mi` / `mi_sei`) is computed from.", - "direction": "lower_better", - "group": "maintainability", - "label": "Span", - "name": "Line span", - "short": "Span", - "value_type": "int" + { + "connections": [ + "in" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/ISP.md", + "id": "ISP", + "label": "ISP", + "prompt": "Clients should not be forced to depend on methods they do not use. Prefer several\nsmall, focused interfaces over one wide interface.\n\nIdentify interfaces in the modules below that are wider than their consumers need.\nPropose how to split them into narrower interfaces so each consumer depends only on\nwhat it actually uses.", + "sort_metric": "items", + "title": "ISP — Interface Segregation Principle" }, - "time": { - "calc": "effort / 18", - "description": "Estimated implementation time, in seconds.", - "direction": "lower_better", - "formula": "effort ÷ 18", - "group": "halstead", - "label": "Time", - "name": "Coding time (s)", - "short": "H.time(s)", - "value_type": "float" + { + "connections": [ + "common", + "out" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/DIP.md", + "id": "DIP", + "label": "DIP", + "prompt": "High-level modules should not depend on low-level modules; both should depend on\nabstractions, and abstractions should not depend on details.\n\nFind places in the modules below where a high-level module depends directly on a\nconcrete low-level type. Propose an abstraction (interface) to invert each such\ndependency, and specify where the concrete implementation should be wired in.", + "sort_metric": "fan_out", + "title": "DIP — Dependency Inversion Principle" }, - "vocabulary": { - "calc": "eta1 + eta2", - "description": "Vocabulary — distinct operators + operands.", - "direction": "lower_better", - "formula": "eta1 + eta2", - "group": "halstead", - "label": "Vocabulary", - "name": "Distinct symbols", - "short": "H.vocab", - "value_type": "float" + { + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/DRY.md", + "id": "DRY", + "label": "DRY", + "prompt": "Every piece of knowledge must have a single authoritative representation.\nDRY is about knowledge duplication, not just code duplication.\n\nIdentify concepts, rules, or policies that are duplicated across the modules\nbelow. For each duplication, propose a canonical location and the refactoring\nneeded to consolidate it.", + "sort_metric": "sloc", + "title": "DRY — Don't Repeat Yourself" }, - "volume": { - "calc": "length * Math.log2(vocabulary)", - "description": "Algorithm size in bits, from distinct operators and operands.", - "direction": "lower_better", - "formula": "length × log₂(vocabulary)", - "group": "halstead", - "label": "Volume", - "name": "Code volume", - "short": "H.vol", - "value_type": "float" - } - }, - "node_kinds": { - "external": { - "external": true, - "fill": "#f6e2c0", - "label": "Library", - "plural": "Libraries", - "stroke": "#b3801f" + { + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/KISS.md", + "id": "KISS", + "label": "KISS", + "prompt": "When two designs solve the same problem, prefer the simpler one — fewer\nabstractions, fewer indirection layers, fewer moving parts.\n\nIdentify over-engineered or needlessly complex constructs in the modules below.\nFor each, describe the simpler alternative and estimate the risk of simplifying.", + "sort_metric": "cognitive", + "title": "KISS — Keep It Simple" }, - "file": { - "fill": "#dbe9f4", - "label": "File", - "plural": "Files", - "stroke": "#4d6f9c" - } - }, - "nodes": [ { - "external": true, - "id": "ext:System", - "kind": "external", - "name": "System" + "connections": [ + "common", + "out" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/LoD.md", + "id": "LoD", + "label": "LoD", + "prompt": "A method should only call methods on: itself, its direct fields,\nits parameters, and objects it constructs locally.\nAvoid `x.foo().bar().baz()` chains that traverse object graphs.\n\nIdentify method chains or deep field traversals in the modules below that\nviolate LoD. For each, propose a narrow accessor or a facade that exposes only\nwhat the caller needs, reducing coupling.", + "sort_metric": "fan_out", + "title": "Law of Demeter — Principle of Least Knowledge" }, { - "blank": 1, - "branches": 3, - "bugs": 0.14, - "cloc": 2, - "closures": 1, - "cognitive": 4, - "cyclomatic": 6, - "effort": 8684.873, - "eta1": 22, - "eta2": 19, - "exits": 1, - "fan_out": 1, - "fan_out_external": 1, - "id": "{target}/Main.cs", - "kind": "file", - "length": 80, - "lloc": 12, - "loc": 21, - "mi": 89.574, - "mi_sei": 77.665, - "n1": 45, - "n2": 35, - "name": "Main.cs", - "sloc": 17, - "spaces": 3, - "span_sloc": 20, - "time": 482.492, - "vocabulary": 41, - "volume": 428.604 + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/MISU.md", + "id": "MISU", + "label": "MISU", + "prompt": "Move correctness from runtime checks into the type system, so invalid states\ncannot be constructed and fail at compile time rather than at runtime.\n\nIdentify data structures or function signatures in the modules below where invalid\nstates are representable at runtime. For each, propose a type-level encoding\n(sum type / enum, newtype, typestate) that makes the invalid state unrepresentable\nby construction.", + "sort_metric": "cyclomatic", + "title": "MISU — Make Invalid States Unrepresentable" }, { - "args": 1, - "blank": 1, - "bugs": 0.0492, - "cloc": 2, - "cyclomatic": 2, - "effort": 1796.263, - "eta1": 13, - "eta2": 11, - "exits": 1, - "fan_in": 1, - "fan_out": 1, - "hk": 9, - "id": "{target}/Mathx.cs", - "kind": "file", - "length": 39, - "lloc": 6, - "loc": 13, - "mi": 103.315, - "mi_sei": 103.111, - "n1": 22, - "n2": 17, - "name": "Mathx.cs", - "sloc": 9, - "spaces": 2, - "span_sloc": 12, - "time": 99.792, - "vocabulary": 24, - "volume": 178.813 + "connections": [ + "common" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/CoI.md", + "id": "CoI", + "label": "CoI", + "prompt": "Build behaviour by composing small, focused pieces rather than through deep\ninheritance hierarchies.\n\nIdentify large types that accumulate behaviour in the modules below. Propose how to\ndecompose them into smaller composable parts, and show how consumers would assemble\nthe behaviour they need.", + "sort_metric": "items", + "title": "CoI — Composition Over Inheritance" }, { - "args": 2, - "branches": 1, - "bugs": 0.0448, - "cloc": 2, - "cognitive": 1, - "cyclomatic": 3, - "effort": 1562.023, - "eta1": 12, - "eta2": 7, - "exits": 2, - "fan_in": 1, - "id": "{target}/Util.cs", - "kind": "file", - "length": 33, - "lloc": 6, - "loc": 13, - "mi": 104.351, - "mi_sei": 104.707, - "n1": 20, - "n2": 13, - "name": "Util.cs", - "sloc": 10, - "spaces": 2, - "span_sloc": 12, - "time": 86.779, - "vocabulary": 19, - "volume": 140.181 + "connections": [ + "out" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/YAGNI.md", + "id": "YAGNI", + "label": "YAGNI", + "prompt": "Build for the problem you have now, not one you imagine you might have later.\nDon't add an abstraction, a generic parameter, or a public API for a hypothetical\nfuture use.\n\nIdentify abstractions, generics, or public APIs in the modules below that were\nadded speculatively. For each, assess whether multiple real callers use it today,\nand propose simplification if not.", + "sort_metric": "sloc", + "title": "YAGNI — You Aren't Gonna Need It" } ], - "stats": { - "blank": 1, - "bugs": 0.078, - "cloc": 2, - "cognitive": 2.5, - "cyclomatic": 3.666, - "effort": 4014.386, - "fan_in": 1, - "fan_out": 1, - "hk": 9, - "length": 50.666, - "mi": 99.08, - "mi_sei": 95.161, - "sloc": 12, - "time": 223.021, - "vocabulary": 28, - "volume": 249.199 - }, - "ui": { - "card": [ - "hk", - "sloc" - ], - "columns": [ - "kind", - "sloc", - "hk", - "fan_in", - "fan_out", - "volume", - "bugs", - "effort", - "time", - "length", - "vocabulary", - "cyclomatic", - "cognitive", - "mi", - "mi_sei", - "lloc", - "cloc", - "blank" - ], - "default_sort": "hk", - "filter": [], - "size": [ - "sloc", - "hk" - ], - "sort": [ - "sloc", - "hk", - "fan_in", - "fan_out", - "volume", - "bugs", - "effort", - "time", - "length", - "vocabulary", - "cyclomatic", - "cognitive", - "mi", - "mi_sei", - "lloc", - "cloc", - "blank" - ], - "summary": [ - "sloc", - "hk", - "fan_in", - "fan_out", - "volume", - "bugs", - "effort", - "time", - "length", - "vocabulary", - "cyclomatic", - "cognitive", - "mi", - "mi_sei", - "lloc", - "cloc", - "blank" + "prompt": { + "cycle_note": "This is **one** dependency cycle; every module in it is listed below so the whole loop is visible. Fix one cycle at a time — `--top 2`+ lists several separate cycles at once and obscures how each one connects.", + "doc_note": "**First, before reading the source**, run `code-ranker docs {id}` — it prints the full principle and, for your language, the usual cause of this exact violation and the smallest correct fix, often with a worked example and how to confirm it. Read it first: it normally names the remedy outright, so you apply it instead of re-deriving the mechanism from the code.", + "focus": "**Focus the research and report primarily on the modules below.**", + "intro": "I want to apply this to some modules in my system.", + "task": [ + "- Prepare a precise, detailed estimate and a report of where the modules below violate it.", + "- If you find more serious violations elsewhere during research, mention them in the report too.", + "- Show a summary of the report in chat.", + "- If any violation is found, suggest saving the report to a file as a plan for a detailed review, named `.code-ranker/-{id}.md`." ] } } }, - "plugin": "csharp", - "principles": [ - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/CPX.md", - "id": "CPX", - "label": "CPX", - "prompt": "These modules are too complex and I want to reduce their complexity.\nReduce it by splitting large units into smaller single-responsibility ones,\nextracting repeated patterns into shared helpers, flattening deeply nested\ncontrol flow, and breaking large functions into focused helpers.", - "sort_metric": "cognitive", - "title": "CPX — Reduce Complexity" - }, - { - "connections": [ - "common" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/ADP.md", - "id": "ADP", - "label": "ADP", - "prompt": "The dependency graph between modules must form a DAG. When module A depends\non module B, no chain of dependencies should bring B back to A.\n\nIdentify any cycles in the modules below. For each cycle, propose a concrete\nrefactoring (extract a shared abstraction, invert a dependency, split a module)\nthat makes the graph acyclic without breaking existing functionality.\n\nWhen splitting a module to break a cycle, the new structure should:\n- Preserve existing API contracts\n- Minimise coupling in the new structure\n- Follow the Single Responsibility Principle\n- Not introduce new dependency cycles", - "sort_metric": "cycle", - "title": "ADP — Acyclic Dependencies Principle" - }, - { - "connections": [ - "in", - "out" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/SRP.md", - "id": "SRP", - "label": "SRP", - "prompt": "A module should have one reason to change — it should serve one actor\nand encapsulate one coherent set of decisions.\n\nFor each module below, identify whether it has more than one responsibility.\nPropose how to split responsibilities so each module changes for only one reason,\nand specify the new module boundaries.", - "sort_metric": "sloc", - "title": "SRP — Single Responsibility Principle" - }, - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/OCP.md", - "id": "OCP", - "label": "OCP", - "prompt": "A module should be open for extension but closed for modification: new behaviour\nshould be addable without editing existing, working code.\n\nFor each module below, identify extension points that currently require editing\nexisting code (e.g. growing match/switch/if-else chains). Propose an extension\nmechanism (polymorphism, strategy, plug-in registration) so new cases can be added\nwithout modifying these modules.", - "sort_metric": "cyclomatic", - "title": "OCP — Open/Closed Principle" - }, - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/LSP.md", - "id": "LSP", - "label": "LSP", - "prompt": "Every implementation of an interface must honour its full contract — return-value\ninvariants, error/exception behaviour, side effects, and resource ownership — not\njust the method signatures. A subtype must be substitutable for its base without\nsurprising callers.\n\nIdentify the interface implementations in the modules below. For each, check it can\nreplace any other implementation of the same interface without breaking callers.\nFlag violations and propose fixes.", - "sort_metric": "hk", - "title": "LSP — Liskov Substitution Principle" - }, - { - "connections": [ - "in" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/ISP.md", - "id": "ISP", - "label": "ISP", - "prompt": "Clients should not be forced to depend on methods they do not use. Prefer several\nsmall, focused interfaces over one wide interface.\n\nIdentify interfaces in the modules below that are wider than their consumers need.\nPropose how to split them into narrower interfaces so each consumer depends only on\nwhat it actually uses.", - "sort_metric": "items", - "title": "ISP — Interface Segregation Principle" - }, - { - "connections": [ - "common", - "out" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/DIP.md", - "id": "DIP", - "label": "DIP", - "prompt": "High-level modules should not depend on low-level modules; both should depend on\nabstractions, and abstractions should not depend on details.\n\nFind places in the modules below where a high-level module depends directly on a\nconcrete low-level type. Propose an abstraction (interface) to invert each such\ndependency, and specify where the concrete implementation should be wired in.", - "sort_metric": "fan_out", - "title": "DIP — Dependency Inversion Principle" - }, - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/DRY.md", - "id": "DRY", - "label": "DRY", - "prompt": "Every piece of knowledge must have a single authoritative representation.\nDRY is about knowledge duplication, not just code duplication.\n\nIdentify concepts, rules, or policies that are duplicated across the modules\nbelow. For each duplication, propose a canonical location and the refactoring\nneeded to consolidate it.", - "sort_metric": "sloc", - "title": "DRY — Don't Repeat Yourself" - }, - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/KISS.md", - "id": "KISS", - "label": "KISS", - "prompt": "When two designs solve the same problem, prefer the simpler one — fewer\nabstractions, fewer indirection layers, fewer moving parts.\n\nIdentify over-engineered or needlessly complex constructs in the modules below.\nFor each, describe the simpler alternative and estimate the risk of simplifying.", - "sort_metric": "cognitive", - "title": "KISS — Keep It Simple" - }, - { - "connections": [ - "common", - "out" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/LoD.md", - "id": "LoD", - "label": "LoD", - "prompt": "A method should only call methods on: itself, its direct fields,\nits parameters, and objects it constructs locally.\nAvoid `x.foo().bar().baz()` chains that traverse object graphs.\n\nIdentify method chains or deep field traversals in the modules below that\nviolate LoD. For each, propose a narrow accessor or a facade that exposes only\nwhat the caller needs, reducing coupling.", - "sort_metric": "fan_out", - "title": "Law of Demeter — Principle of Least Knowledge" - }, - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/MISU.md", - "id": "MISU", - "label": "MISU", - "prompt": "Move correctness from runtime checks into the type system, so invalid states\ncannot be constructed and fail at compile time rather than at runtime.\n\nIdentify data structures or function signatures in the modules below where invalid\nstates are representable at runtime. For each, propose a type-level encoding\n(sum type / enum, newtype, typestate) that makes the invalid state unrepresentable\nby construction.", - "sort_metric": "cyclomatic", - "title": "MISU — Make Invalid States Unrepresentable" - }, - { - "connections": [ - "common" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/CoI.md", - "id": "CoI", - "label": "CoI", - "prompt": "Build behaviour by composing small, focused pieces rather than through deep\ninheritance hierarchies.\n\nIdentify large types that accumulate behaviour in the modules below. Propose how to\ndecompose them into smaller composable parts, and show how consumers would assemble\nthe behaviour they need.", - "sort_metric": "items", - "title": "CoI — Composition Over Inheritance" - }, - { - "connections": [ - "out" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/YAGNI.md", - "id": "YAGNI", - "label": "YAGNI", - "prompt": "Build for the problem you have now, not one you imagine you might have later.\nDon't add an abstraction, a generic parameter, or a public API for a hypothetical\nfuture use.\n\nIdentify abstractions, generics, or public APIs in the modules below that were\nadded speculatively. For each, assess whether multiple real callers use it today,\nand propose simplification if not.", - "sort_metric": "sloc", - "title": "YAGNI — You Aren't Gonna Need It" - } + "plugins": [ + "csharp" ], - "prompt": { - "cycle_note": "This is **one** dependency cycle; every module in it is listed below so the whole loop is visible. Fix one cycle at a time — `--top 2`+ lists several separate cycles at once and obscures how each one connects.", - "doc_note": "**First, before reading the source**, run `code-ranker docs {id}` — it prints the full principle and, for your language, the usual cause of this exact violation and the smallest correct fix, often with a worked example and how to confirm it. Read it first: it normally names the remedy outright, so you apply it instead of re-deriving the mechanism from the code.", - "focus": "**Focus the research and report primarily on the modules below.**", - "intro": "I want to apply this to some modules in my system.", - "task": [ - "- Prepare a precise, detailed estimate and a report of where the modules below violate it.", - "- If you find more serious violations elsewhere during research, mention them in the report too.", - "- Show a summary of the report in chat.", - "- If any violation is found, suggest saving the report to a file as a plan for a detailed review, named `.code-ranker/-{id}.md`." - ] - }, "roots": { "target": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/csharp/tests/sample" }, - "schema_version": "4.0", + "schema_version": "5.0", "target": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/csharp/tests/sample", "timings": [ { "detail": "4 nodes from 3 files", "ms": 0, - "stage": "csharp" + "stage": "csharp: parse" }, { "detail": "3 nodes annotated", "ms": 0, - "stage": "complexity" + "stage": "csharp: complexity" }, { "detail": "nodes=4 edges=3", "ms": 0, - "stage": "projection" + "stage": "csharp: projection" } ], "versions": { - "code-ranker": "4.0.0" + "code-ranker": "5.0.0" }, "workspace": "/home/user/code-ranker" } diff --git a/crates/code-ranker-plugins/src/languages/csharp/tests/sample/code-ranker.toml b/crates/code-ranker-plugins/src/languages/csharp/tests/sample/code-ranker.toml index b9216f41..a0ea3560 100644 --- a/crates/code-ranker-plugins/src/languages/csharp/tests/sample/code-ranker.toml +++ b/crates/code-ranker-plugins/src/languages/csharp/tests/sample/code-ranker.toml @@ -1,6 +1,6 @@ -version = "4.0" +version = "5.0" # Self-contained config for the code-ranker "csharp" sample fixture. -plugin = "csharp" - -[ignore] +[plugins] +enabled = ["csharp"] +[plugins.base.ignore] tests = false diff --git a/crates/code-ranker-plugins/src/languages/ecmascript/config.toml b/crates/code-ranker-plugins/src/languages/ecmascript/config.toml index d5c7fff9..024f7a2d 100644 --- a/crates/code-ranker-plugins/src/languages/ecmascript/config.toml +++ b/crates/code-ranker-plugins/src/languages/ecmascript/config.toml @@ -207,11 +207,5 @@ statement_kinds.named = [ [specs.eta1] description = "Distinct operators (η₁): the count of unique operator token kinds. JavaScript / TypeScript count the keywords `export import from as extends new function let var const return delete throw break continue if else switch case default for in of while try catch finally with async await yield`, arithmetic / logical / comparison / assignment operators (`+ - * / % ** ++ -- && || ! == === != !== < <= > >= = += -= *= /= %= **= ?? ?`), bitwise operators (`& | ^ << >> >>> ~`), and punctuation / delimiters (`. , : ; ( [ { @`)." -[specs.n1] -description = "Total operators (N₁): every operator occurrence counted with repetition (the η₁ tokens, not deduplicated)." - [specs.eta2] description = "Distinct operands (η₂): the count of unique operand texts. JavaScript / TypeScript count identifiers (including `member_expression` / `property_identifier` / `nested_identifier`), literals (`string`, `number`, `true`, `false`, `null`, `undefined`, `void`), `this` / `super`, and the contextual keywords `set` / `get` / `typeof` / `instanceof`." - -[specs.n2] -description = "Total operands (N₂): every operand occurrence counted with repetition (the η₂ texts, not deduplicated)." diff --git a/crates/code-ranker-plugins/src/languages/ecmascript/mod.rs b/crates/code-ranker-plugins/src/languages/ecmascript/mod.rs index 82370e70..a600b8cb 100644 --- a/crates/code-ranker-plugins/src/languages/ecmascript/mod.rs +++ b/crates/code-ranker-plugins/src/languages/ecmascript/mod.rs @@ -54,14 +54,16 @@ pub fn ecmascript_level(name: &str, cfg: &toml::Table) -> Level { } } -/// Apply the shared ECMAScript `[specs.]` description overrides (from -/// `ecmascript/config.toml`) over the central builtin metric specs — used by both -/// the JS and TS plugins, since they share the same `[halstead]` operator/operand -/// vocabulary, so the exact-tokens descriptions live in one place. +/// Apply the shared ECMAScript `[specs.]` description overrides from the +/// provided (effective) config over the central builtin metric specs — used by +/// both the JS and TS plugins, since they share the same `[halstead]` +/// operator/operand vocabulary, so the exact-tokens descriptions live in one +/// place. pub fn ecmascript_metric_specs( defaults: BTreeMap, + cfg: &toml::Table, ) -> BTreeMap { - crate::config::apply_spec_overrides(defaults, &cfg::CONFIG) + crate::config::apply_spec_overrides(defaults, cfg) } /// Measure ECMAScript complexity metrics for every `file` node, shared by the diff --git a/crates/code-ranker-plugins/src/languages/ecmascript/tests/dialect.rs b/crates/code-ranker-plugins/src/languages/ecmascript/tests/dialect.rs index a584f678..22655888 100644 --- a/crates/code-ranker-plugins/src/languages/ecmascript/tests/dialect.rs +++ b/crates/code-ranker-plugins/src/languages/ecmascript/tests/dialect.rs @@ -201,12 +201,12 @@ fn typescript_trigger_set_documented_in_spec() { // the TypeScript metrics spec, so the trigger list and the spec's "Keyword // look-alike guard set" cannot drift apart. let root = concat!(env!("CARGO_MANIFEST_DIR"), "/../.."); - let path = format!("{root}/languages/typescript/metrics.md"); + let path = format!("{root}/plugins/ts/metrics.md"); let spec = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path}: {e}")); for kw in TS_TRIGGERS { assert!( spec.contains(&format!("`{kw}`")), - "trigger `{kw}` is not documented in languages/typescript/metrics.md — spec and FP test drifted" + "trigger `{kw}` is not documented in plugins/ts/metrics.md — spec and FP test drifted" ); } } diff --git a/crates/code-ranker-plugins/src/languages/go/config.toml b/crates/code-ranker-plugins/src/languages/go/config.toml index 0790d320..f649ba80 100644 --- a/crates/code-ranker-plugins/src/languages/go/config.toml +++ b/crates/code-ranker-plugins/src/languages/go/config.toml @@ -13,6 +13,8 @@ doc_lang = "go" # File-collection extensions, project-detect markers, and walk skip-dirs — DATA. extensions = ["go"] detect_markers = ["go.mod"] +# Short aliases accepted anywhere a language is named. Unique across languages. +aliases = ["golang"] skip_dirs = ["vendor", "testdata", "node_modules"] # Test-path conventions — DATA (the predicate LOGIC stays in `structure.rs`). @@ -109,11 +111,5 @@ statement_kinds.named = [ [specs.eta1] description = "Distinct operators (η₁): the count of unique operator token kinds. Go counts punctuation & delimiters (`( { [ , . ; :`), arithmetic / bitwise / comparison / assignment operators (`+ - * / % ++ -- == != < > <= >= && || ! & | ^ << >> &^ = := += -= *= /= %= &= |= ^= <<= >>= &^=`), the channel / variadic / unary tokens (`<- ... ~`), and the keywords `break case chan const continue default defer else fallthrough for func go goto if import interface map package range return select struct switch type var`." -[specs.n1] -description = "Total operators (N₁): every operator occurrence counted with repetition (the η₁ tokens, not deduplicated)." - [specs.eta2] description = "Distinct operands (η₂): the count of unique operand texts. Go counts identifiers (incl. field / package / type identifiers, labels and the blank `_`), literals (int, float, imaginary, rune, interpreted- and raw-string), and the predeclared `true` / `false` / `nil` / `iota`." - -[specs.n2] -description = "Total operands (N₂): every operand occurrence counted with repetition (the η₂ texts, not deduplicated)." diff --git a/crates/code-ranker-plugins/src/languages/go/mod.rs b/crates/code-ranker-plugins/src/languages/go/mod.rs index 4685ee51..dc97ab50 100644 --- a/crates/code-ranker-plugins/src/languages/go/mod.rs +++ b/crates/code-ranker-plugins/src/languages/go/mod.rs @@ -42,15 +42,15 @@ impl LanguagePlugin for GoPlugin { "go" } - fn detect(&self, workspace: &Path, _input: &PluginInput) -> bool { - crate::config::string_list(&CONFIG, "detect_markers") + fn detect(&self, cfg: &toml::Table, workspace: &Path, _input: &PluginInput) -> bool { + crate::config::string_list(cfg, "detect_markers") .iter() .any(|f| workspace.join(f).exists()) } - fn levels(&self) -> Vec { - let edge_kinds = crate::config::edge_kinds(&CONFIG); - let node_attributes = crate::config::node_attributes(&CONFIG); + fn levels(&self, cfg: &toml::Table) -> Vec { + let edge_kinds = crate::config::edge_kinds(cfg); + let node_attributes = crate::config::node_attributes(cfg); vec![ Level { name: "files".into(), @@ -68,14 +68,14 @@ impl LanguagePlugin for GoPlugin { node_attributes: BTreeMap::new(), edge_attributes: BTreeMap::new(), attribute_groups: BTreeMap::new(), - node_kinds: function_node_kinds(), + node_kinds: function_node_kinds(cfg), cycle_kinds: default_cycle_kinds(), grouping: None, }, ] } - fn analyze(&self, workspace: &Path, input: &PluginInput) -> Result { + fn analyze(&self, _cfg: &toml::Table, workspace: &Path, input: &PluginInput) -> Result { structure::analyze( workspace, input.ignore_tests, @@ -83,27 +83,31 @@ impl LanguagePlugin for GoPlugin { ) } - fn metrics(&self, graph: &Graph) -> Vec<(String, MetricInputs)> { + fn metrics(&self, _cfg: &toml::Table, graph: &Graph) -> Vec<(String, MetricInputs)> { file_metrics(graph) } - fn function_units(&self, graph: &Graph) -> Vec<(Node, MetricInputs)> { + fn function_units(&self, _cfg: &toml::Table, graph: &Graph) -> Vec<(Node, MetricInputs)> { function_nodes(graph) } - fn principles(&self, _input: &PluginInput) -> Vec { - crate::config::resolved_principles(&CONFIG) + fn principles(&self, cfg: &toml::Table, _input: &PluginInput) -> Vec { + crate::config::resolved_principles(cfg) } - fn report_overrides(&self) -> code_ranker_plugin_api::report::ReportOverride { - code_ranker_plugin_api::list_override::report_override(&CONFIG) + fn report_overrides( + &self, + cfg: &toml::Table, + ) -> code_ranker_plugin_api::report::ReportOverride { + code_ranker_plugin_api::list_override::report_override(cfg) } fn metric_specs( &self, + cfg: &toml::Table, defaults: BTreeMap, ) -> BTreeMap { - crate::config::apply_spec_overrides(defaults, &CONFIG) + crate::config::apply_spec_overrides(defaults, cfg) } } @@ -127,8 +131,8 @@ fn file_metrics(graph: &Graph) -> Vec<(String, MetricInputs)> { /// Per-language unit kinds for the `functions` level (inherited `function` / /// `method` from `defaults.toml`; Go adds none of its own). -fn function_node_kinds() -> BTreeMap { - crate::config::node_kinds(&CONFIG) +fn function_node_kinds(cfg: &toml::Table) -> BTreeMap { + crate::config::node_kinds(cfg) } /// Build function-level units for every `file` node. diff --git a/crates/code-ranker-plugins/src/languages/go/tests/mod_rs.rs b/crates/code-ranker-plugins/src/languages/go/tests/mod_rs.rs index 07d20d31..cbb20db0 100644 --- a/crates/code-ranker-plugins/src/languages/go/tests/mod_rs.rs +++ b/crates/code-ranker-plugins/src/languages/go/tests/mod_rs.rs @@ -6,16 +6,18 @@ use super::*; fn detects_by_go_mod() { let d = tempfile::tempdir().unwrap(); let p = GoPlugin; - assert!(!p.detect(d.path(), &PluginInput::default())); + let cfg = p.config(); + assert!(!p.detect(&cfg, d.path(), &PluginInput::default())); std::fs::write(d.path().join("go.mod"), "module m\n").unwrap(); - assert!(p.detect(d.path(), &PluginInput::default())); + assert!(p.detect(&cfg, d.path(), &PluginInput::default())); } #[test] fn name_and_levels() { let p = GoPlugin; + let cfg = p.config(); assert_eq!(p.name(), "go"); - let levels = p.levels(); + let levels = p.levels(&cfg); assert!(levels.iter().any(|l| l.name == "files")); assert!(levels.iter().any(|l| l.name == "functions")); } @@ -31,9 +33,10 @@ fn metrics_and_function_units_over_a_temp_project() { .unwrap(); let p = GoPlugin; - let g = p.analyze(d.path(), &PluginInput::default()).unwrap(); - assert!(!p.metrics(&g).is_empty(), "file metrics produced"); - let units = p.function_units(&g); + let cfg = p.config(); + let g = p.analyze(&cfg, d.path(), &PluginInput::default()).unwrap(); + assert!(!p.metrics(&cfg, &g).is_empty(), "file metrics produced"); + let units = p.function_units(&cfg, &g); assert!(units.iter().any(|(n, _)| n.name == "A"), "function unit A"); } @@ -56,6 +59,7 @@ fn metrics_skip_non_file_and_unreadable_nodes() { ], edges: vec![], }; - assert!(GoPlugin.metrics(&g).is_empty()); - assert!(GoPlugin.function_units(&g).is_empty()); + let cfg = GoPlugin.config(); + assert!(GoPlugin.metrics(&cfg, &g).is_empty()); + assert!(GoPlugin.function_units(&cfg, &g).is_empty()); } diff --git a/crates/code-ranker-plugins/src/languages/go/tests/sample/code-ranker-check.sarif b/crates/code-ranker-plugins/src/languages/go/tests/sample/code-ranker-check.sarif index 03beb451..b2abc2cf 100644 --- a/crates/code-ranker-plugins/src/languages/go/tests/sample/code-ranker-check.sarif +++ b/crates/code-ranker-plugins/src/languages/go/tests/sample/code-ranker-check.sarif @@ -8,7 +8,7 @@ "informationUri": "https://github.com/ffedoroff/code-ranker", "name": "code-ranker", "rules": [], - "version": "3.0.0-alpha.1" + "version": "4.0.0" } } } diff --git a/crates/code-ranker-plugins/src/languages/go/tests/sample/code-ranker-report.json b/crates/code-ranker-plugins/src/languages/go/tests/sample/code-ranker-report.json index f32c85dc..f8cefadd 100644 --- a/crates/code-ranker-plugins/src/languages/go/tests/sample/code-ranker-report.json +++ b/crates/code-ranker-plugins/src/languages/go/tests/sample/code-ranker-report.json @@ -1,5 +1,5 @@ { - "command": "code-ranker report crates/code-ranker-plugins/src/languages/go/tests/sample --config crates/code-ranker-plugins/src/languages/go/tests/sample/code-ranker.toml --output.json.path=crates/code-ranker-plugins/src/languages/go/tests/sample/code-ranker-report.json --output.mode quiet", + "command": "code-ranker report crates/code-ranker-plugins/src/languages/go/tests/sample --config crates/code-ranker-plugins/src/languages/go/tests/sample/code-ranker.toml --output.json.path=crates/code-ranker-plugins/src/languages/go/tests/sample/code-ranker-report.json", "config_file": "crates/code-ranker-plugins/src/languages/go/tests/sample/code-ranker.toml", "generated_at": "1970-01-01T00:00:00Z", "git": { @@ -8,713 +8,719 @@ "dirty_files": 0, "origin": "git@example.com:org/repo.git" }, - "graphs": { - "files": { - "attribute_groups": { - "complexity": { - "description": "per-function branching, nesting & size", - "label": "Complexity" - }, - "coupling": { - "description": "how tightly modules depend on each other", - "label": "Coupling" - }, - "halstead": { - "description": "operator/operand vocabulary & derived effort", - "label": "Halstead" - }, - "loc": { - "description": "physical line counts", - "label": "Lines of Code" - }, - "maintainability": { - "description": "composite score", - "label": "Maintainability" - } - }, - "cycle_kinds": {}, - "cycles": [], - "edge_attributes": {}, - "edge_kinds": { - "uses": { - "description": "Import dependency — this file imports from the other.", - "flow": true, - "label": "uses" + "languages": { + "go": { + "graphs": { + "files": { + "attribute_groups": { + "complexity": { + "description": "per-function branching, nesting & size", + "label": "Complexity" + }, + "coupling": { + "description": "how tightly modules depend on each other", + "label": "Coupling" + }, + "halstead": { + "description": "operator/operand vocabulary & derived effort", + "label": "Halstead" + }, + "loc": { + "description": "physical line counts", + "label": "Lines of Code" + }, + "maintainability": { + "description": "composite score", + "label": "Maintainability" + } + }, + "cycle_kinds": {}, + "cycles": [], + "edge_attributes": {}, + "edge_kinds": { + "uses": { + "description": "Import dependency — this file imports from the other.", + "flow": true, + "label": "uses" + } + }, + "edges": [ + { + "kind": "uses", + "line": 5, + "source": "{target}/main.go", + "target": "ext:fmt" + }, + { + "kind": "uses", + "line": 7, + "source": "{target}/main.go", + "target": "{target}/mathx/mathx.go" + }, + { + "kind": "uses", + "line": 4, + "source": "{target}/mathx/mathx.go", + "target": "{target}/util/util.go" + } + ], + "node_attributes": { + "args": { + "description": "Number of function / closure arguments.", + "direction": "lower_better", + "group": "complexity", + "label": "Args", + "name": "Arguments", + "short": "Args", + "value_type": "int" + }, + "blank": { + "description": "Empty or whitespace-only lines.", + "group": "loc", + "label": "Blank", + "name": "Blank lines", + "short": "Blank", + "value_type": "int" + }, + "branches": { + "description": "Decision points: if / for / while / loop / match arm / try / && / ||. Feeds `cyclomatic`.", + "direction": "lower_better", + "group": "complexity", + "label": "Branches", + "name": "Decision points", + "short": "Branches", + "value_type": "int" + }, + "bugs": { + "calc": "effort ** (2/3) / 3000", + "description": "Estimated delivered bugs — a rough predictor of defect density.", + "direction": "lower_better", + "formula": "effort^⅔ ÷ 3000", + "group": "halstead", + "label": "Bugs", + "name": "Estimated bugs", + "short": "H.bugs", + "value_type": "float" + }, + "cloc": { + "description": "Comment-only lines (inline comments on code lines are not counted).", + "group": "loc", + "label": "Comments", + "name": "Comment lines", + "short": "Comments", + "value_type": "int" + }, + "closures": { + "description": "Number of closures defined in the unit.", + "direction": "lower_better", + "group": "complexity", + "label": "Closures", + "name": "Closures defined", + "short": "Closures", + "value_type": "int" + }, + "cognitive": { + "description": "How hard the code is for a human to follow — not just how many paths it has.
Like `cyclomatic` it adds +1 for each break in linear flow (`if`, `else`, `match`, loops, `catch`, chained `&&` / `||`), but it also adds an extra +1 for every level of nesting: an `if` inside a loop inside an `if` costs far more than three flat `if`s.
That nesting penalty is the point — deeply indented logic is what actually strains a reader, so a high `cognitive` next to a modest `cyclomatic` flags tangled, hard-to-read code.
Summed across every function in the file.", + "direction": "lower_better", + "group": "complexity", + "label": "Cognitive", + "name": "Cognitive complexity", + "short": "Cognitive", + "value_type": "int" + }, + "cyclomatic": { + "calc": "spaces + branches", + "description": "Number of independent paths through the code — roughly the minimum number of test cases needed to cover every branch.
A function starts at 1 and gains +1 per decision point: each `if` / `else if`, every `match` / `switch` arm, every loop, and each `&&` / `||` in a condition.
Summed across every function in the file, so it grows with both size and branching — the file's total branching burden.
Counts paths only, ignoring how deeply they nest. For a readability-weighted view see `cognitive`.", + "direction": "lower_better", + "formula": "spaces + branches", + "group": "complexity", + "label": "Cyclomatic", + "name": "Cyclomatic complexity", + "omit_at": 1.0, + "short": "Cyclomatic", + "value_type": "int" + }, + "effort": { + "calc": "(eta1 / 2) * (n2 / eta2) * volume", + "description": "Mental effort to implement the algorithm.", + "direction": "lower_better", + "formula": "(eta1 ÷ 2) × (n2 ÷ eta2) × volume", + "group": "halstead", + "label": "Effort", + "name": "Implementation effort", + "short": "H.effort", + "value_type": "float" + }, + "eta1": { + "description": "Distinct operators (η₁): the count of unique operator token kinds. Go counts punctuation & delimiters (`( { [ , . ; :`), arithmetic / bitwise / comparison / assignment operators (`+ - * / % ++ -- == != < > <= >= && || ! & | ^ << >> &^ = := += -= *= /= %= &= |= ^= <<= >>= &^=`), the channel / variadic / unary tokens (`<- ... ~`), and the keywords `break case chan const continue default defer else fallthrough for func go goto if import interface map package range return select struct switch type var`.", + "direction": "lower_better", + "group": "halstead", + "label": "η₁", + "name": "Unique operators", + "short": "η₁", + "value_type": "int" + }, + "eta2": { + "description": "Distinct operands (η₂): the count of unique operand texts. Go counts identifiers (incl. field / package / type identifiers, labels and the blank `_`), literals (int, float, imaginary, rune, interpreted- and raw-string), and the predeclared `true` / `false` / `nil` / `iota`.", + "direction": "lower_better", + "group": "halstead", + "label": "η₂", + "name": "Unique operands", + "short": "η₂", + "value_type": "int" + }, + "exits": { + "description": "Number of exit points (return/throw) in the unit.", + "direction": "lower_better", + "group": "complexity", + "label": "Exits", + "name": "Exit points", + "short": "Exits", + "value_type": "int" + }, + "external": { + "label": "External", + "value_type": "bool" + }, + "fan_in": { + "description": "Many other units depend on this one, making it risky to change and a single point of failure — though some hubs (shared types) carry high fan-in legitimately.", + "group": "coupling", + "label": "Fan-in", + "name": "Incoming dependencies", + "short": "Fan-in", + "value_type": "int" + }, + "fan_out": { + "description": "This unit depends on many others, so it breaks when any of them change and is hard to test in isolation.", + "group": "coupling", + "label": "Fan-out", + "name": "Outgoing dependencies", + "short": "Fan-out", + "value_type": "int" + }, + "fan_out_external": { + "description": "Number of distinct external libraries this node depends on.", + "group": "coupling", + "label": "Fan-out (external)", + "name": "External dependencies", + "short": "Fan-out (external)", + "value_type": "int" + }, + "hk": { + "abbreviate": true, + "calc": "sloc * (fan_in * fan_out) ** 2", + "description": "Henry-Kafura information-flow complexity: a module that is both a busy crossroads (high fan-in × fan-out) and large — the most expensive place in the codebase to change.", + "direction": "lower_better", + "formula": "sloc × (fan_in × fan_out)²", + "group": "coupling", + "label": "HK", + "name": "God-object risk", + "short": "HK", + "value_type": "float" + }, + "length": { + "calc": "n1 + n2", + "description": "Program length — total operator + operand occurrences.", + "direction": "lower_better", + "formula": "n1 + n2", + "group": "halstead", + "label": "Length", + "name": "Total tokens", + "short": "H.len", + "value_type": "float" + }, + "lloc": { + "description": "Logical lines — counts statements, not physical lines.", + "group": "loc", + "label": "Logical", + "name": "Logical lines", + "short": "Logical", + "value_type": "int" + }, + "loc": { + "description": "Raw file line count, including blank and comment lines (unlike `sloc`). Large files tend to hold several responsibilities and are harder to review, test, and reuse.", + "group": "loc", + "label": "Lines", + "name": "Total lines", + "remediation": "Split by responsibility into smaller units, extract helpers, and separate data definitions from behavior. For an average breach, break up the largest units first (--top).", + "value_type": "int" + }, + "mi": { + "calc": "171 - 5.2*Math.log(volume) - 0.23*cyclomatic - 16.2*Math.log(span_sloc)", + "description": "Maintainability Index (0–100, higher is more maintainable). Derived from Halstead volume, cyclomatic complexity, and SLOC.", + "direction": "higher_better", + "formula": "171 − 5.2·ln(volume) − 0.23·cyclomatic − 16.2·ln(span_sloc)", + "group": "maintainability", + "label": "MI", + "name": "Maintainability index", + "short": "MI", + "value_type": "float" + }, + "mi_sei": { + "calc": "171 - 5.2*Math.log2(volume) - 0.23*cyclomatic - 16.2*Math.log2(span_sloc) + 50*Math.sin(Math.sqrt(cloc / span_sloc * 2.4))", + "description": "SEI variant of the Maintainability Index — adds a bonus for comment density.", + "direction": "higher_better", + "formula": "171 − 5.2·log₂(volume) − 0.23·cyclomatic − 16.2·log₂(span_sloc) + 50·sin(√(cloc ÷ span_sloc × 2.4))", + "group": "maintainability", + "label": "MI (SEI)", + "name": "Maintainability (SEI)", + "short": "MI SEI", + "value_type": "float" + }, + "n1": { + "description": "Total operators (N₁): every operator occurrence counted with repetition (the η₁ tokens, not deduplicated).", + "direction": "lower_better", + "group": "halstead", + "label": "N₁", + "name": "Total operators", + "short": "N₁", + "value_type": "int" + }, + "n2": { + "description": "Total operands (N₂): every operand occurrence counted with repetition (the η₂ texts, not deduplicated).", + "direction": "lower_better", + "group": "halstead", + "label": "N₂", + "name": "Total operands", + "short": "N₂", + "value_type": "int" + }, + "sloc": { + "description": "Source lines of code — lines with at least one non-whitespace, non-comment character. Blank and comment-only lines are not counted (unlike `loc`, the raw file line count).", + "group": "loc", + "label": "Source", + "name": "Source lines", + "short": "SLOC", + "value_type": "int" + }, + "spaces": { + "description": "Unit count: the source file (1) plus each function / impl / trait / closure space. Feeds `cyclomatic`.", + "direction": "lower_better", + "group": "complexity", + "label": "Spaces", + "name": "Code units", + "short": "Spaces", + "value_type": "int" + }, + "span_sloc": { + "description": "Line span of the unit (end_row − start_row) — the size input the Maintainability Index (`mi` / `mi_sei`) is computed from.", + "direction": "lower_better", + "group": "maintainability", + "label": "Span", + "name": "Line span", + "short": "Span", + "value_type": "int" + }, + "time": { + "calc": "effort / 18", + "description": "Estimated implementation time, in seconds.", + "direction": "lower_better", + "formula": "effort ÷ 18", + "group": "halstead", + "label": "Time", + "name": "Coding time (s)", + "short": "H.time(s)", + "value_type": "float" + }, + "vocabulary": { + "calc": "eta1 + eta2", + "description": "Vocabulary — distinct operators + operands.", + "direction": "lower_better", + "formula": "eta1 + eta2", + "group": "halstead", + "label": "Vocabulary", + "name": "Distinct symbols", + "short": "H.vocab", + "value_type": "float" + }, + "volume": { + "calc": "length * Math.log2(vocabulary)", + "description": "Algorithm size in bits, from distinct operators and operands.", + "direction": "lower_better", + "formula": "length × log₂(vocabulary)", + "group": "halstead", + "label": "Volume", + "name": "Code volume", + "short": "H.vol", + "value_type": "float" + } + }, + "node_kinds": { + "external": { + "external": true, + "fill": "#f6e2c0", + "label": "Library", + "plural": "Libraries", + "stroke": "#b3801f" + }, + "file": { + "fill": "#dbe9f4", + "label": "File", + "plural": "Files", + "stroke": "#4d6f9c" + } + }, + "nodes": [ + { + "external": true, + "id": "ext:fmt", + "kind": "external", + "name": "fmt" + }, + { + "args": 1, + "blank": 4, + "branches": 3, + "bugs": 0.105, + "cloc": 4, + "closures": 1, + "cognitive": 4, + "cyclomatic": 6, + "effort": 5664.651, + "eta1": 19, + "eta2": 16, + "exits": 1, + "fan_out": 1, + "fan_out_external": 1, + "id": "{target}/main.go", + "kind": "file", + "length": 62, + "lloc": 10, + "loc": 24, + "mi": 88.862, + "mi_sei": 83.213, + "n1": 32, + "n2": 30, + "name": "main.go", + "sloc": 15, + "spaces": 3, + "span_sloc": 23, + "time": 314.702, + "vocabulary": 35, + "volume": 318.015 + }, + { + "args": 1, + "blank": 2, + "bugs": 0.0271, + "cloc": 2, + "cyclomatic": 2, + "effort": 736.307, + "eta1": 10, + "eta2": 9, + "exits": 1, + "fan_in": 1, + "fan_out": 1, + "hk": 6, + "id": "{target}/mathx/mathx.go", + "kind": "file", + "length": 24, + "lloc": 3, + "loc": 11, + "mi": 109.19, + "mi_sei": 113.967, + "n1": 11, + "n2": 13, + "name": "mathx.go", + "sloc": 6, + "spaces": 2, + "span_sloc": 10, + "time": 40.905, + "vocabulary": 19, + "volume": 101.95 + }, + { + "args": 1, + "blank": 1, + "branches": 1, + "bugs": 0.0235, + "cloc": 2, + "cognitive": 1, + "cyclomatic": 3, + "effort": 592.07, + "eta1": 8, + "eta2": 5, + "exits": 2, + "fan_in": 1, + "id": "{target}/util/util.go", + "kind": "file", + "length": 20, + "lloc": 3, + "loc": 11, + "mi": 110.626, + "mi_sei": 116.14, + "n1": 10, + "n2": 10, + "name": "util.go", + "sloc": 7, + "spaces": 2, + "span_sloc": 10, + "time": 32.892, + "vocabulary": 13, + "volume": 74.008 + } + ], + "stats": { + "blank": 2.333, + "bugs": 0.0518, + "cloc": 2.666, + "cognitive": 2.5, + "cyclomatic": 3.666, + "effort": 2331.009, + "fan_in": 1, + "fan_out": 1, + "hk": 6, + "length": 35.333, + "mi": 102.892, + "mi_sei": 104.44, + "sloc": 9.333, + "time": 129.499, + "vocabulary": 22.333, + "volume": 164.657 + }, + "ui": { + "card": [ + "hk", + "sloc" + ], + "columns": [ + "kind", + "sloc", + "hk", + "fan_in", + "fan_out", + "volume", + "bugs", + "effort", + "time", + "length", + "vocabulary", + "cyclomatic", + "cognitive", + "mi", + "mi_sei", + "lloc", + "cloc", + "blank" + ], + "default_sort": "hk", + "filter": [], + "size": [ + "sloc", + "hk" + ], + "sort": [ + "sloc", + "hk", + "fan_in", + "fan_out", + "volume", + "bugs", + "effort", + "time", + "length", + "vocabulary", + "cyclomatic", + "cognitive", + "mi", + "mi_sei", + "lloc", + "cloc", + "blank" + ], + "summary": [ + "sloc", + "hk", + "fan_in", + "fan_out", + "volume", + "bugs", + "effort", + "time", + "length", + "vocabulary", + "cyclomatic", + "cognitive", + "mi", + "mi_sei", + "lloc", + "cloc", + "blank" + ] + } } }, - "edges": [ + "principles": [ { - "kind": "uses", - "line": 5, - "source": "{target}/main.go", - "target": "ext:fmt" + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/CPX.md", + "id": "CPX", + "label": "CPX", + "prompt": "These modules are too complex and I want to reduce their complexity.\nReduce it by splitting large units into smaller single-responsibility ones,\nextracting repeated patterns into shared helpers, flattening deeply nested\ncontrol flow, and breaking large functions into focused helpers.", + "sort_metric": "cognitive", + "title": "CPX — Reduce Complexity" }, { - "kind": "uses", - "line": 7, - "source": "{target}/main.go", - "target": "{target}/mathx/mathx.go" + "connections": [ + "common" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/ADP.md", + "id": "ADP", + "label": "ADP", + "prompt": "The dependency graph between modules must form a DAG. When module A depends\non module B, no chain of dependencies should bring B back to A.\n\nIdentify any cycles in the modules below. For each cycle, propose a concrete\nrefactoring (extract a shared abstraction, invert a dependency, split a module)\nthat makes the graph acyclic without breaking existing functionality.\n\nWhen splitting a module to break a cycle, the new structure should:\n- Preserve existing API contracts\n- Minimise coupling in the new structure\n- Follow the Single Responsibility Principle\n- Not introduce new dependency cycles", + "sort_metric": "cycle", + "title": "ADP — Acyclic Dependencies Principle" }, { - "kind": "uses", - "line": 4, - "source": "{target}/mathx/mathx.go", - "target": "{target}/util/util.go" - } - ], - "node_attributes": { - "args": { - "description": "Number of function / closure arguments.", - "direction": "lower_better", - "group": "complexity", - "label": "Args", - "name": "Arguments", - "short": "Args", - "value_type": "int" - }, - "blank": { - "description": "Empty or whitespace-only lines.", - "group": "loc", - "label": "Blank", - "name": "Blank lines", - "short": "Blank", - "value_type": "int" - }, - "branches": { - "description": "Decision points: if / for / while / loop / match arm / try / && / ||. Feeds `cyclomatic`.", - "direction": "lower_better", - "group": "complexity", - "label": "Branches", - "name": "Decision points", - "short": "Branches", - "value_type": "int" - }, - "bugs": { - "calc": "effort ** (2/3) / 3000", - "description": "Estimated delivered bugs — a rough predictor of defect density.", - "direction": "lower_better", - "formula": "effort^⅔ ÷ 3000", - "group": "halstead", - "label": "Bugs", - "name": "Estimated bugs", - "short": "H.bugs", - "value_type": "float" - }, - "cloc": { - "description": "Comment-only lines (inline comments on code lines are not counted).", - "group": "loc", - "label": "Comments", - "name": "Comment lines", - "short": "Comments", - "value_type": "int" - }, - "closures": { - "description": "Number of closures defined in the unit.", - "direction": "lower_better", - "group": "complexity", - "label": "Closures", - "name": "Closures defined", - "short": "Closures", - "value_type": "int" - }, - "cognitive": { - "description": "How hard the code is for a human to follow — not just how many paths it has.
Like `cyclomatic` it adds +1 for each break in linear flow (`if`, `else`, `match`, loops, `catch`, chained `&&` / `||`), but it also adds an extra +1 for every level of nesting: an `if` inside a loop inside an `if` costs far more than three flat `if`s.
That nesting penalty is the point — deeply indented logic is what actually strains a reader, so a high `cognitive` next to a modest `cyclomatic` flags tangled, hard-to-read code.
Summed across every function in the file.", - "direction": "lower_better", - "group": "complexity", - "label": "Cognitive", - "name": "Cognitive complexity", - "short": "Cognitive", - "value_type": "int" - }, - "cyclomatic": { - "calc": "spaces + branches", - "description": "Number of independent paths through the code — roughly the minimum number of test cases needed to cover every branch.
A function starts at 1 and gains +1 per decision point: each `if` / `else if`, every `match` / `switch` arm, every loop, and each `&&` / `||` in a condition.
Summed across every function in the file, so it grows with both size and branching — the file's total branching burden.
Counts paths only, ignoring how deeply they nest. For a readability-weighted view see `cognitive`.", - "direction": "lower_better", - "formula": "spaces + branches", - "group": "complexity", - "label": "Cyclomatic", - "name": "Cyclomatic complexity", - "omit_at": 1.0, - "short": "Cyclomatic", - "value_type": "int" - }, - "effort": { - "calc": "(eta1 / 2) * (n2 / eta2) * volume", - "description": "Mental effort to implement the algorithm.", - "direction": "lower_better", - "formula": "(eta1 ÷ 2) × (n2 ÷ eta2) × volume", - "group": "halstead", - "label": "Effort", - "name": "Implementation effort", - "short": "H.effort", - "value_type": "float" - }, - "eta1": { - "description": "Distinct operators (η₁): the count of unique operator token kinds. Go counts punctuation & delimiters (`( { [ , . ; :`), arithmetic / bitwise / comparison / assignment operators (`+ - * / % ++ -- == != < > <= >= && || ! & | ^ << >> &^ = := += -= *= /= %= &= |= ^= <<= >>= &^=`), the channel / variadic / unary tokens (`<- ... ~`), and the keywords `break case chan const continue default defer else fallthrough for func go goto if import interface map package range return select struct switch type var`.", - "direction": "lower_better", - "group": "halstead", - "label": "η₁", - "name": "Unique operators", - "short": "η₁", - "value_type": "int" - }, - "eta2": { - "description": "Distinct operands (η₂): the count of unique operand texts. Go counts identifiers (incl. field / package / type identifiers, labels and the blank `_`), literals (int, float, imaginary, rune, interpreted- and raw-string), and the predeclared `true` / `false` / `nil` / `iota`.", - "direction": "lower_better", - "group": "halstead", - "label": "η₂", - "name": "Unique operands", - "short": "η₂", - "value_type": "int" - }, - "exits": { - "description": "Number of exit points (return/throw) in the unit.", - "direction": "lower_better", - "group": "complexity", - "label": "Exits", - "name": "Exit points", - "short": "Exits", - "value_type": "int" + "connections": [ + "in", + "out" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/SRP.md", + "id": "SRP", + "label": "SRP", + "prompt": "A module should have one reason to change — it should serve one actor\nand encapsulate one coherent set of decisions.\n\nFor each module below, identify whether it has more than one responsibility.\nPropose how to split responsibilities so each module changes for only one reason,\nand specify the new module boundaries.", + "sort_metric": "sloc", + "title": "SRP — Single Responsibility Principle" }, - "external": { - "label": "External", - "value_type": "bool" - }, - "fan_in": { - "description": "Many other units depend on this one, making it risky to change and a single point of failure — though some hubs (shared types) carry high fan-in legitimately.", - "group": "coupling", - "label": "Fan-in", - "name": "Incoming dependencies", - "short": "Fan-in", - "value_type": "int" - }, - "fan_out": { - "description": "This unit depends on many others, so it breaks when any of them change and is hard to test in isolation.", - "group": "coupling", - "label": "Fan-out", - "name": "Outgoing dependencies", - "short": "Fan-out", - "value_type": "int" - }, - "fan_out_external": { - "description": "Number of distinct external libraries this node depends on.", - "group": "coupling", - "label": "Fan-out (external)", - "name": "External dependencies", - "short": "Fan-out (external)", - "value_type": "int" - }, - "hk": { - "abbreviate": true, - "calc": "sloc * (fan_in * fan_out) ** 2", - "description": "Henry-Kafura information-flow complexity: a module that is both a busy crossroads (high fan-in × fan-out) and large — the most expensive place in the codebase to change.", - "direction": "lower_better", - "formula": "sloc × (fan_in × fan_out)²", - "group": "coupling", - "label": "HK", - "name": "God-object risk", - "short": "HK", - "value_type": "float" - }, - "length": { - "calc": "n1 + n2", - "description": "Program length — total operator + operand occurrences.", - "direction": "lower_better", - "formula": "n1 + n2", - "group": "halstead", - "label": "Length", - "name": "Total tokens", - "short": "H.len", - "value_type": "float" - }, - "lloc": { - "description": "Logical lines — counts statements, not physical lines.", - "group": "loc", - "label": "Logical", - "name": "Logical lines", - "short": "Logical", - "value_type": "int" - }, - "loc": { - "description": "Raw file line count, including blank and comment lines (unlike `sloc`). Large files tend to hold several responsibilities and are harder to review, test, and reuse.", - "group": "loc", - "label": "Lines", - "name": "Total lines", - "remediation": "Split by responsibility into smaller units, extract helpers, and separate data definitions from behavior. For an average breach, break up the largest units first (--top).", - "value_type": "int" - }, - "mi": { - "calc": "171 - 5.2*Math.log(volume) - 0.23*cyclomatic - 16.2*Math.log(span_sloc)", - "description": "Maintainability Index (0–100, higher is more maintainable). Derived from Halstead volume, cyclomatic complexity, and SLOC.", - "direction": "higher_better", - "formula": "171 − 5.2·ln(volume) − 0.23·cyclomatic − 16.2·ln(span_sloc)", - "group": "maintainability", - "label": "MI", - "name": "Maintainability index", - "short": "MI", - "value_type": "float" - }, - "mi_sei": { - "calc": "171 - 5.2*Math.log2(volume) - 0.23*cyclomatic - 16.2*Math.log2(span_sloc) + 50*Math.sin(Math.sqrt(cloc / span_sloc * 2.4))", - "description": "SEI variant of the Maintainability Index — adds a bonus for comment density.", - "direction": "higher_better", - "formula": "171 − 5.2·log₂(volume) − 0.23·cyclomatic − 16.2·log₂(span_sloc) + 50·sin(√(cloc ÷ span_sloc × 2.4))", - "group": "maintainability", - "label": "MI (SEI)", - "name": "Maintainability (SEI)", - "short": "MI SEI", - "value_type": "float" - }, - "n1": { - "description": "Total operators (N₁): every operator occurrence counted with repetition (the η₁ tokens, not deduplicated).", - "direction": "lower_better", - "group": "halstead", - "label": "N₁", - "name": "Total operators", - "short": "N₁", - "value_type": "int" - }, - "n2": { - "description": "Total operands (N₂): every operand occurrence counted with repetition (the η₂ texts, not deduplicated).", - "direction": "lower_better", - "group": "halstead", - "label": "N₂", - "name": "Total operands", - "short": "N₂", - "value_type": "int" - }, - "sloc": { - "description": "Source lines of code — lines with at least one non-whitespace, non-comment character. Blank and comment-only lines are not counted (unlike `loc`, the raw file line count).", - "group": "loc", - "label": "Source", - "name": "Source lines", - "short": "SLOC", - "value_type": "int" + { + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/OCP.md", + "id": "OCP", + "label": "OCP", + "prompt": "A module should be open for extension but closed for modification: new behaviour\nshould be addable without editing existing, working code.\n\nFor each module below, identify extension points that currently require editing\nexisting code (e.g. growing match/switch/if-else chains). Propose an extension\nmechanism (polymorphism, strategy, plug-in registration) so new cases can be added\nwithout modifying these modules.", + "sort_metric": "cyclomatic", + "title": "OCP — Open/Closed Principle" }, - "spaces": { - "description": "Unit count: the source file (1) plus each function / impl / trait / closure space. Feeds `cyclomatic`.", - "direction": "lower_better", - "group": "complexity", - "label": "Spaces", - "name": "Code units", - "short": "Spaces", - "value_type": "int" + { + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/LSP.md", + "id": "LSP", + "label": "LSP", + "prompt": "Every implementation of an interface must honour its full contract — return-value\ninvariants, error/exception behaviour, side effects, and resource ownership — not\njust the method signatures. A subtype must be substitutable for its base without\nsurprising callers.\n\nIdentify the interface implementations in the modules below. For each, check it can\nreplace any other implementation of the same interface without breaking callers.\nFlag violations and propose fixes.", + "sort_metric": "hk", + "title": "LSP — Liskov Substitution Principle" }, - "span_sloc": { - "description": "Line span of the unit (end_row − start_row) — the size input the Maintainability Index (`mi` / `mi_sei`) is computed from.", - "direction": "lower_better", - "group": "maintainability", - "label": "Span", - "name": "Line span", - "short": "Span", - "value_type": "int" + { + "connections": [ + "in" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/ISP.md", + "id": "ISP", + "label": "ISP", + "prompt": "Clients should not be forced to depend on methods they do not use. Prefer several\nsmall, focused interfaces over one wide interface.\n\nIdentify interfaces in the modules below that are wider than their consumers need.\nPropose how to split them into narrower interfaces so each consumer depends only on\nwhat it actually uses.", + "sort_metric": "items", + "title": "ISP — Interface Segregation Principle" }, - "time": { - "calc": "effort / 18", - "description": "Estimated implementation time, in seconds.", - "direction": "lower_better", - "formula": "effort ÷ 18", - "group": "halstead", - "label": "Time", - "name": "Coding time (s)", - "short": "H.time(s)", - "value_type": "float" + { + "connections": [ + "common", + "out" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/DIP.md", + "id": "DIP", + "label": "DIP", + "prompt": "High-level modules should not depend on low-level modules; both should depend on\nabstractions, and abstractions should not depend on details.\n\nFind places in the modules below where a high-level module depends directly on a\nconcrete low-level type. Propose an abstraction (interface) to invert each such\ndependency, and specify where the concrete implementation should be wired in.", + "sort_metric": "fan_out", + "title": "DIP — Dependency Inversion Principle" }, - "vocabulary": { - "calc": "eta1 + eta2", - "description": "Vocabulary — distinct operators + operands.", - "direction": "lower_better", - "formula": "eta1 + eta2", - "group": "halstead", - "label": "Vocabulary", - "name": "Distinct symbols", - "short": "H.vocab", - "value_type": "float" + { + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/DRY.md", + "id": "DRY", + "label": "DRY", + "prompt": "Every piece of knowledge must have a single authoritative representation.\nDRY is about knowledge duplication, not just code duplication.\n\nIdentify concepts, rules, or policies that are duplicated across the modules\nbelow. For each duplication, propose a canonical location and the refactoring\nneeded to consolidate it.", + "sort_metric": "sloc", + "title": "DRY — Don't Repeat Yourself" }, - "volume": { - "calc": "length * Math.log2(vocabulary)", - "description": "Algorithm size in bits, from distinct operators and operands.", - "direction": "lower_better", - "formula": "length × log₂(vocabulary)", - "group": "halstead", - "label": "Volume", - "name": "Code volume", - "short": "H.vol", - "value_type": "float" - } - }, - "node_kinds": { - "external": { - "external": true, - "fill": "#f6e2c0", - "label": "Library", - "plural": "Libraries", - "stroke": "#b3801f" + { + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/KISS.md", + "id": "KISS", + "label": "KISS", + "prompt": "When two designs solve the same problem, prefer the simpler one — fewer\nabstractions, fewer indirection layers, fewer moving parts.\n\nIdentify over-engineered or needlessly complex constructs in the modules below.\nFor each, describe the simpler alternative and estimate the risk of simplifying.", + "sort_metric": "cognitive", + "title": "KISS — Keep It Simple" }, - "file": { - "fill": "#dbe9f4", - "label": "File", - "plural": "Files", - "stroke": "#4d6f9c" - } - }, - "nodes": [ { - "external": true, - "id": "ext:fmt", - "kind": "external", - "name": "fmt" + "connections": [ + "common", + "out" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/LoD.md", + "id": "LoD", + "label": "LoD", + "prompt": "A method should only call methods on: itself, its direct fields,\nits parameters, and objects it constructs locally.\nAvoid `x.foo().bar().baz()` chains that traverse object graphs.\n\nIdentify method chains or deep field traversals in the modules below that\nviolate LoD. For each, propose a narrow accessor or a facade that exposes only\nwhat the caller needs, reducing coupling.", + "sort_metric": "fan_out", + "title": "Law of Demeter — Principle of Least Knowledge" }, { - "args": 1, - "blank": 4, - "branches": 3, - "bugs": 0.105, - "cloc": 4, - "closures": 1, - "cognitive": 4, - "cyclomatic": 6, - "effort": 5664.651, - "eta1": 19, - "eta2": 16, - "exits": 1, - "fan_out": 1, - "fan_out_external": 1, - "id": "{target}/main.go", - "kind": "file", - "length": 62, - "lloc": 10, - "loc": 24, - "mi": 88.862, - "mi_sei": 83.213, - "n1": 32, - "n2": 30, - "name": "main.go", - "sloc": 15, - "spaces": 3, - "span_sloc": 23, - "time": 314.702, - "vocabulary": 35, - "volume": 318.015 + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/MISU.md", + "id": "MISU", + "label": "MISU", + "prompt": "Move correctness from runtime checks into the type system, so invalid states\ncannot be constructed and fail at compile time rather than at runtime.\n\nIdentify data structures or function signatures in the modules below where invalid\nstates are representable at runtime. For each, propose a type-level encoding\n(sum type / enum, newtype, typestate) that makes the invalid state unrepresentable\nby construction.", + "sort_metric": "cyclomatic", + "title": "MISU — Make Invalid States Unrepresentable" }, { - "args": 1, - "blank": 2, - "bugs": 0.0271, - "cloc": 2, - "cyclomatic": 2, - "effort": 736.307, - "eta1": 10, - "eta2": 9, - "exits": 1, - "fan_in": 1, - "fan_out": 1, - "hk": 6, - "id": "{target}/mathx/mathx.go", - "kind": "file", - "length": 24, - "lloc": 3, - "loc": 11, - "mi": 109.19, - "mi_sei": 113.967, - "n1": 11, - "n2": 13, - "name": "mathx.go", - "sloc": 6, - "spaces": 2, - "span_sloc": 10, - "time": 40.905, - "vocabulary": 19, - "volume": 101.95 + "connections": [ + "common" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/CoI.md", + "id": "CoI", + "label": "CoI", + "prompt": "Build behaviour by composing small, focused pieces rather than through deep\ninheritance hierarchies.\n\nIdentify large types that accumulate behaviour in the modules below. Propose how to\ndecompose them into smaller composable parts, and show how consumers would assemble\nthe behaviour they need.", + "sort_metric": "items", + "title": "CoI — Composition Over Inheritance" }, { - "args": 1, - "blank": 1, - "branches": 1, - "bugs": 0.0235, - "cloc": 2, - "cognitive": 1, - "cyclomatic": 3, - "effort": 592.07, - "eta1": 8, - "eta2": 5, - "exits": 2, - "fan_in": 1, - "id": "{target}/util/util.go", - "kind": "file", - "length": 20, - "lloc": 3, - "loc": 11, - "mi": 110.626, - "mi_sei": 116.14, - "n1": 10, - "n2": 10, - "name": "util.go", - "sloc": 7, - "spaces": 2, - "span_sloc": 10, - "time": 32.892, - "vocabulary": 13, - "volume": 74.008 + "connections": [ + "out" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/YAGNI.md", + "id": "YAGNI", + "label": "YAGNI", + "prompt": "Build for the problem you have now, not one you imagine you might have later.\nDon't add an abstraction, a generic parameter, or a public API for a hypothetical\nfuture use.\n\nIdentify abstractions, generics, or public APIs in the modules below that were\nadded speculatively. For each, assess whether multiple real callers use it today,\nand propose simplification if not.", + "sort_metric": "sloc", + "title": "YAGNI — You Aren't Gonna Need It" } ], - "stats": { - "blank": 2.333, - "bugs": 0.0518, - "cloc": 2.666, - "cognitive": 2.5, - "cyclomatic": 3.666, - "effort": 2331.009, - "fan_in": 1, - "fan_out": 1, - "hk": 6, - "length": 35.333, - "mi": 102.892, - "mi_sei": 104.44, - "sloc": 9.333, - "time": 129.499, - "vocabulary": 22.333, - "volume": 164.657 - }, - "ui": { - "card": [ - "hk", - "sloc" - ], - "columns": [ - "kind", - "sloc", - "hk", - "fan_in", - "fan_out", - "volume", - "bugs", - "effort", - "time", - "length", - "vocabulary", - "cyclomatic", - "cognitive", - "mi", - "mi_sei", - "lloc", - "cloc", - "blank" - ], - "default_sort": "hk", - "filter": [], - "size": [ - "sloc", - "hk" - ], - "sort": [ - "sloc", - "hk", - "fan_in", - "fan_out", - "volume", - "bugs", - "effort", - "time", - "length", - "vocabulary", - "cyclomatic", - "cognitive", - "mi", - "mi_sei", - "lloc", - "cloc", - "blank" - ], - "summary": [ - "sloc", - "hk", - "fan_in", - "fan_out", - "volume", - "bugs", - "effort", - "time", - "length", - "vocabulary", - "cyclomatic", - "cognitive", - "mi", - "mi_sei", - "lloc", - "cloc", - "blank" + "prompt": { + "cycle_note": "This is **one** dependency cycle; every module in it is listed below so the whole loop is visible. Fix one cycle at a time — `--top 2`+ lists several separate cycles at once and obscures how each one connects.", + "doc_note": "**First, before reading the source**, run `code-ranker docs {id}` — it prints the full principle and, for your language, the usual cause of this exact violation and the smallest correct fix, often with a worked example and how to confirm it. Read it first: it normally names the remedy outright, so you apply it instead of re-deriving the mechanism from the code.", + "focus": "**Focus the research and report primarily on the modules below.**", + "intro": "I want to apply this to some modules in my system.", + "task": [ + "- Prepare a precise, detailed estimate and a report of where the modules below violate it.", + "- If you find more serious violations elsewhere during research, mention them in the report too.", + "- Show a summary of the report in chat.", + "- If any violation is found, suggest saving the report to a file as a plan for a detailed review, named `.code-ranker/-{id}.md`." ] } } }, - "plugin": "go", - "principles": [ - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/CPX.md", - "id": "CPX", - "label": "CPX", - "prompt": "These modules are too complex and I want to reduce their complexity.\nReduce it by splitting large units into smaller single-responsibility ones,\nextracting repeated patterns into shared helpers, flattening deeply nested\ncontrol flow, and breaking large functions into focused helpers.", - "sort_metric": "cognitive", - "title": "CPX — Reduce Complexity" - }, - { - "connections": [ - "common" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/ADP.md", - "id": "ADP", - "label": "ADP", - "prompt": "The dependency graph between modules must form a DAG. When module A depends\non module B, no chain of dependencies should bring B back to A.\n\nIdentify any cycles in the modules below. For each cycle, propose a concrete\nrefactoring (extract a shared abstraction, invert a dependency, split a module)\nthat makes the graph acyclic without breaking existing functionality.\n\nWhen splitting a module to break a cycle, the new structure should:\n- Preserve existing API contracts\n- Minimise coupling in the new structure\n- Follow the Single Responsibility Principle\n- Not introduce new dependency cycles", - "sort_metric": "cycle", - "title": "ADP — Acyclic Dependencies Principle" - }, - { - "connections": [ - "in", - "out" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/SRP.md", - "id": "SRP", - "label": "SRP", - "prompt": "A module should have one reason to change — it should serve one actor\nand encapsulate one coherent set of decisions.\n\nFor each module below, identify whether it has more than one responsibility.\nPropose how to split responsibilities so each module changes for only one reason,\nand specify the new module boundaries.", - "sort_metric": "sloc", - "title": "SRP — Single Responsibility Principle" - }, - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/OCP.md", - "id": "OCP", - "label": "OCP", - "prompt": "A module should be open for extension but closed for modification: new behaviour\nshould be addable without editing existing, working code.\n\nFor each module below, identify extension points that currently require editing\nexisting code (e.g. growing match/switch/if-else chains). Propose an extension\nmechanism (polymorphism, strategy, plug-in registration) so new cases can be added\nwithout modifying these modules.", - "sort_metric": "cyclomatic", - "title": "OCP — Open/Closed Principle" - }, - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/LSP.md", - "id": "LSP", - "label": "LSP", - "prompt": "Every implementation of an interface must honour its full contract — return-value\ninvariants, error/exception behaviour, side effects, and resource ownership — not\njust the method signatures. A subtype must be substitutable for its base without\nsurprising callers.\n\nIdentify the interface implementations in the modules below. For each, check it can\nreplace any other implementation of the same interface without breaking callers.\nFlag violations and propose fixes.", - "sort_metric": "hk", - "title": "LSP — Liskov Substitution Principle" - }, - { - "connections": [ - "in" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/ISP.md", - "id": "ISP", - "label": "ISP", - "prompt": "Clients should not be forced to depend on methods they do not use. Prefer several\nsmall, focused interfaces over one wide interface.\n\nIdentify interfaces in the modules below that are wider than their consumers need.\nPropose how to split them into narrower interfaces so each consumer depends only on\nwhat it actually uses.", - "sort_metric": "items", - "title": "ISP — Interface Segregation Principle" - }, - { - "connections": [ - "common", - "out" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/DIP.md", - "id": "DIP", - "label": "DIP", - "prompt": "High-level modules should not depend on low-level modules; both should depend on\nabstractions, and abstractions should not depend on details.\n\nFind places in the modules below where a high-level module depends directly on a\nconcrete low-level type. Propose an abstraction (interface) to invert each such\ndependency, and specify where the concrete implementation should be wired in.", - "sort_metric": "fan_out", - "title": "DIP — Dependency Inversion Principle" - }, - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/DRY.md", - "id": "DRY", - "label": "DRY", - "prompt": "Every piece of knowledge must have a single authoritative representation.\nDRY is about knowledge duplication, not just code duplication.\n\nIdentify concepts, rules, or policies that are duplicated across the modules\nbelow. For each duplication, propose a canonical location and the refactoring\nneeded to consolidate it.", - "sort_metric": "sloc", - "title": "DRY — Don't Repeat Yourself" - }, - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/KISS.md", - "id": "KISS", - "label": "KISS", - "prompt": "When two designs solve the same problem, prefer the simpler one — fewer\nabstractions, fewer indirection layers, fewer moving parts.\n\nIdentify over-engineered or needlessly complex constructs in the modules below.\nFor each, describe the simpler alternative and estimate the risk of simplifying.", - "sort_metric": "cognitive", - "title": "KISS — Keep It Simple" - }, - { - "connections": [ - "common", - "out" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/LoD.md", - "id": "LoD", - "label": "LoD", - "prompt": "A method should only call methods on: itself, its direct fields,\nits parameters, and objects it constructs locally.\nAvoid `x.foo().bar().baz()` chains that traverse object graphs.\n\nIdentify method chains or deep field traversals in the modules below that\nviolate LoD. For each, propose a narrow accessor or a facade that exposes only\nwhat the caller needs, reducing coupling.", - "sort_metric": "fan_out", - "title": "Law of Demeter — Principle of Least Knowledge" - }, - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/MISU.md", - "id": "MISU", - "label": "MISU", - "prompt": "Move correctness from runtime checks into the type system, so invalid states\ncannot be constructed and fail at compile time rather than at runtime.\n\nIdentify data structures or function signatures in the modules below where invalid\nstates are representable at runtime. For each, propose a type-level encoding\n(sum type / enum, newtype, typestate) that makes the invalid state unrepresentable\nby construction.", - "sort_metric": "cyclomatic", - "title": "MISU — Make Invalid States Unrepresentable" - }, - { - "connections": [ - "common" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/CoI.md", - "id": "CoI", - "label": "CoI", - "prompt": "Build behaviour by composing small, focused pieces rather than through deep\ninheritance hierarchies.\n\nIdentify large types that accumulate behaviour in the modules below. Propose how to\ndecompose them into smaller composable parts, and show how consumers would assemble\nthe behaviour they need.", - "sort_metric": "items", - "title": "CoI — Composition Over Inheritance" - }, - { - "connections": [ - "out" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/YAGNI.md", - "id": "YAGNI", - "label": "YAGNI", - "prompt": "Build for the problem you have now, not one you imagine you might have later.\nDon't add an abstraction, a generic parameter, or a public API for a hypothetical\nfuture use.\n\nIdentify abstractions, generics, or public APIs in the modules below that were\nadded speculatively. For each, assess whether multiple real callers use it today,\nand propose simplification if not.", - "sort_metric": "sloc", - "title": "YAGNI — You Aren't Gonna Need It" - } + "plugins": [ + "go" ], - "prompt": { - "cycle_note": "This is **one** dependency cycle; every module in it is listed below so the whole loop is visible. Fix one cycle at a time — `--top 2`+ lists several separate cycles at once and obscures how each one connects.", - "doc_note": "**First, before reading the source**, run `code-ranker docs {id}` — it prints the full principle and, for your language, the usual cause of this exact violation and the smallest correct fix, often with a worked example and how to confirm it. Read it first: it normally names the remedy outright, so you apply it instead of re-deriving the mechanism from the code.", - "focus": "**Focus the research and report primarily on the modules below.**", - "intro": "I want to apply this to some modules in my system.", - "task": [ - "- Prepare a precise, detailed estimate and a report of where the modules below violate it.", - "- If you find more serious violations elsewhere during research, mention them in the report too.", - "- Show a summary of the report in chat.", - "- If any violation is found, suggest saving the report to a file as a plan for a detailed review, named `.code-ranker/-{id}.md`." - ] - }, "roots": { "target": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/go/tests/sample" }, - "schema_version": "4.0", + "schema_version": "5.0", "target": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/go/tests/sample", "timings": [ { "detail": "4 nodes from 3 files", "ms": 0, - "stage": "go" + "stage": "go: parse" }, { "detail": "3 nodes annotated", "ms": 0, - "stage": "complexity" + "stage": "go: complexity" }, { "detail": "nodes=4 edges=3", "ms": 0, - "stage": "projection" + "stage": "go: projection" } ], "versions": { - "code-ranker": "4.0.0" + "code-ranker": "5.0.0" }, "workspace": "/home/user/code-ranker" } diff --git a/crates/code-ranker-plugins/src/languages/go/tests/sample/code-ranker.toml b/crates/code-ranker-plugins/src/languages/go/tests/sample/code-ranker.toml index e5276617..84c6545a 100644 --- a/crates/code-ranker-plugins/src/languages/go/tests/sample/code-ranker.toml +++ b/crates/code-ranker-plugins/src/languages/go/tests/sample/code-ranker.toml @@ -1,8 +1,8 @@ -version = "4.0" +version = "5.0" # Self-contained config for the code-ranker "go" sample fixture. # Pin the plugin and keep test files in the graph (ignore.tests = false) so the # fixture is reproducible regardless of any repo-level config. -plugin = "go" - -[ignore] +[plugins] +enabled = ["go"] +[plugins.base.ignore] tests = false diff --git a/crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker-report.json b/crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker-report.json deleted file mode 100644 index 576d77a1..00000000 --- a/crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker-report.json +++ /dev/null @@ -1,962 +0,0 @@ -{ - "command": "code-ranker report crates/code-ranker-plugins/src/languages/javascript/tests/sample --config crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker.toml --output.json.path=crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker-report.json --output.mode quiet", - "config_file": "crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker.toml", - "generated_at": "1970-01-01T00:00:00Z", - "git": { - "branch": "main", - "commit": "000000000000", - "dirty_files": 0, - "origin": "git@example.com:org/repo.git" - }, - "graphs": { - "files": { - "attribute_groups": { - "complexity": { - "description": "per-function branching, nesting & size", - "label": "Complexity" - }, - "coupling": { - "description": "how tightly modules depend on each other", - "label": "Coupling" - }, - "halstead": { - "description": "operator/operand vocabulary & derived effort", - "label": "Halstead" - }, - "loc": { - "description": "physical line counts", - "label": "Lines of Code" - }, - "maintainability": { - "description": "composite score", - "label": "Maintainability" - } - }, - "cycle_kinds": { - "chain": { - "description": "Three or more units form a strongly-connected component (A → B → C → A); the whole component must be loaded and changed together, defeating modular boundaries.", - "label": "Chain", - "remediation": "Run `code-ranker docs ADP` and follow its instructions." - }, - "mutual": { - "description": "Two units import each other (A ↔ B), so neither can be built, tested, or understood in isolation — the tightest possible coupling.", - "label": "Mutual", - "remediation": "Run `code-ranker docs ADP` and follow its instructions." - } - }, - "cycles": [ - { - "kind": "mutual", - "nodes": [ - "{target}/src/m1.js", - "{target}/src/m2.js" - ] - }, - { - "kind": "chain", - "nodes": [ - "{target}/src/a.js", - "{target}/src/b.js", - "{target}/src/c.js" - ] - } - ], - "edge_attributes": {}, - "edge_kinds": { - "uses": { - "description": "Import dependency — this file imports from the other.", - "flow": true, - "label": "uses" - } - }, - "edges": [ - { - "kind": "uses", - "line": 11, - "source": "{target}/src/a.js", - "target": "ext:chalk" - }, - { - "kind": "uses", - "line": 9, - "source": "{target}/src/a.js", - "target": "ext:lodash" - }, - { - "kind": "uses", - "line": 5, - "source": "{target}/src/a.js", - "target": "{target}/src/b.js" - }, - { - "kind": "uses", - "line": 7, - "source": "{target}/src/a.js", - "target": "{target}/src/c.js" - }, - { - "kind": "uses", - "line": 3, - "source": "{target}/src/a.test.js", - "target": "{target}/src/a.js" - }, - { - "kind": "uses", - "line": 4, - "source": "{target}/src/b.js", - "target": "{target}/src/a.js" - }, - { - "kind": "uses", - "line": 6, - "source": "{target}/src/b.js", - "target": "{target}/src/c.js" - }, - { - "kind": "uses", - "line": 7, - "source": "{target}/src/c.js", - "target": "ext:chalk" - }, - { - "kind": "uses", - "line": 5, - "source": "{target}/src/c.js", - "target": "{target}/src/b.js" - }, - { - "kind": "uses", - "line": 3, - "source": "{target}/src/m1.js", - "target": "{target}/src/m2.js" - }, - { - "kind": "uses", - "line": 2, - "source": "{target}/src/m2.js", - "target": "{target}/src/m1.js" - } - ], - "node_attributes": { - "args": { - "description": "Number of function / closure arguments.", - "direction": "lower_better", - "group": "complexity", - "label": "Args", - "name": "Arguments", - "short": "Args", - "value_type": "int" - }, - "blank": { - "description": "Empty or whitespace-only lines.", - "group": "loc", - "label": "Blank", - "name": "Blank lines", - "short": "Blank", - "value_type": "int" - }, - "branches": { - "description": "Decision points: if / for / while / loop / match arm / try / && / ||. Feeds `cyclomatic`.", - "direction": "lower_better", - "group": "complexity", - "label": "Branches", - "name": "Decision points", - "short": "Branches", - "value_type": "int" - }, - "bugs": { - "calc": "effort ** (2/3) / 3000", - "description": "Estimated delivered bugs — a rough predictor of defect density.", - "direction": "lower_better", - "formula": "effort^⅔ ÷ 3000", - "group": "halstead", - "label": "Bugs", - "name": "Estimated bugs", - "short": "H.bugs", - "value_type": "float" - }, - "cloc": { - "description": "Comment-only lines (inline comments on code lines are not counted).", - "group": "loc", - "label": "Comments", - "name": "Comment lines", - "short": "Comments", - "value_type": "int" - }, - "closures": { - "description": "Number of closures defined in the unit.", - "direction": "lower_better", - "group": "complexity", - "label": "Closures", - "name": "Closures defined", - "short": "Closures", - "value_type": "int" - }, - "cognitive": { - "description": "How hard the code is for a human to follow — not just how many paths it has.
Like `cyclomatic` it adds +1 for each break in linear flow (`if`, `else`, `match`, loops, `catch`, chained `&&` / `||`), but it also adds an extra +1 for every level of nesting: an `if` inside a loop inside an `if` costs far more than three flat `if`s.
That nesting penalty is the point — deeply indented logic is what actually strains a reader, so a high `cognitive` next to a modest `cyclomatic` flags tangled, hard-to-read code.
Summed across every function in the file.", - "direction": "lower_better", - "group": "complexity", - "label": "Cognitive", - "name": "Cognitive complexity", - "short": "Cognitive", - "value_type": "int" - }, - "cycle": { - "description": "Cycle kind this node participates in.", - "group": "coupling", - "label": "Cycle", - "name": "Dependency cycle", - "short": "Cycle", - "value_type": "str" - }, - "cyclomatic": { - "calc": "spaces + branches", - "description": "Number of independent paths through the code — roughly the minimum number of test cases needed to cover every branch.
A function starts at 1 and gains +1 per decision point: each `if` / `else if`, every `match` / `switch` arm, every loop, and each `&&` / `||` in a condition.
Summed across every function in the file, so it grows with both size and branching — the file's total branching burden.
Counts paths only, ignoring how deeply they nest. For a readability-weighted view see `cognitive`.", - "direction": "lower_better", - "formula": "spaces + branches", - "group": "complexity", - "label": "Cyclomatic", - "name": "Cyclomatic complexity", - "omit_at": 1.0, - "short": "Cyclomatic", - "value_type": "int" - }, - "effort": { - "calc": "(eta1 / 2) * (n2 / eta2) * volume", - "description": "Mental effort to implement the algorithm.", - "direction": "lower_better", - "formula": "(eta1 ÷ 2) × (n2 ÷ eta2) × volume", - "group": "halstead", - "label": "Effort", - "name": "Implementation effort", - "short": "H.effort", - "value_type": "float" - }, - "eta1": { - "description": "Distinct operators (η₁): the count of unique operator token kinds. JavaScript / TypeScript count the keywords `export import from as extends new function let var const return delete throw break continue if else switch case default for in of while try catch finally with async await yield`, arithmetic / logical / comparison / assignment operators (`+ - * / % ** ++ -- && || ! == === != !== < <= > >= = += -= *= /= %= **= ?? ?`), bitwise operators (`& | ^ << >> >>> ~`), and punctuation / delimiters (`. , : ; ( [ { @`).", - "direction": "lower_better", - "group": "halstead", - "label": "η₁", - "name": "Unique operators", - "short": "η₁", - "value_type": "int" - }, - "eta2": { - "description": "Distinct operands (η₂): the count of unique operand texts. JavaScript / TypeScript count identifiers (including `member_expression` / `property_identifier` / `nested_identifier`), literals (`string`, `number`, `true`, `false`, `null`, `undefined`, `void`), `this` / `super`, and the contextual keywords `set` / `get` / `typeof` / `instanceof`.", - "direction": "lower_better", - "group": "halstead", - "label": "η₂", - "name": "Unique operands", - "short": "η₂", - "value_type": "int" - }, - "exits": { - "description": "Number of exit points (return/throw) in the unit.", - "direction": "lower_better", - "group": "complexity", - "label": "Exits", - "name": "Exit points", - "short": "Exits", - "value_type": "int" - }, - "external": { - "label": "External", - "value_type": "bool" - }, - "fan_in": { - "description": "Many other units depend on this one, making it risky to change and a single point of failure — though some hubs (shared types) carry high fan-in legitimately.", - "group": "coupling", - "label": "Fan-in", - "name": "Incoming dependencies", - "short": "Fan-in", - "value_type": "int" - }, - "fan_out": { - "description": "This unit depends on many others, so it breaks when any of them change and is hard to test in isolation.", - "group": "coupling", - "label": "Fan-out", - "name": "Outgoing dependencies", - "short": "Fan-out", - "value_type": "int" - }, - "fan_out_external": { - "description": "Number of distinct external libraries this node depends on.", - "group": "coupling", - "label": "Fan-out (external)", - "name": "External dependencies", - "short": "Fan-out (external)", - "value_type": "int" - }, - "hk": { - "abbreviate": true, - "calc": "sloc * (fan_in * fan_out) ** 2", - "description": "Henry-Kafura information-flow complexity: a module that is both a busy crossroads (high fan-in × fan-out) and large — the most expensive place in the codebase to change.", - "direction": "lower_better", - "formula": "sloc × (fan_in × fan_out)²", - "group": "coupling", - "label": "HK", - "name": "God-object risk", - "short": "HK", - "value_type": "float" - }, - "length": { - "calc": "n1 + n2", - "description": "Program length — total operator + operand occurrences.", - "direction": "lower_better", - "formula": "n1 + n2", - "group": "halstead", - "label": "Length", - "name": "Total tokens", - "short": "H.len", - "value_type": "float" - }, - "lloc": { - "description": "Logical lines — counts statements, not physical lines.", - "group": "loc", - "label": "Logical", - "name": "Logical lines", - "short": "Logical", - "value_type": "int" - }, - "loc": { - "description": "Raw file line count, including blank and comment lines (unlike `sloc`). Large files tend to hold several responsibilities and are harder to review, test, and reuse.", - "group": "loc", - "label": "Lines", - "name": "Total lines", - "remediation": "Split by responsibility into smaller units, extract helpers, and separate data definitions from behavior. For an average breach, break up the largest units first (--top).", - "value_type": "int" - }, - "mi": { - "calc": "171 - 5.2*Math.log(volume) - 0.23*cyclomatic - 16.2*Math.log(span_sloc)", - "description": "Maintainability Index (0–100, higher is more maintainable). Derived from Halstead volume, cyclomatic complexity, and SLOC.", - "direction": "higher_better", - "formula": "171 − 5.2·ln(volume) − 0.23·cyclomatic − 16.2·ln(span_sloc)", - "group": "maintainability", - "label": "MI", - "name": "Maintainability index", - "short": "MI", - "value_type": "float" - }, - "mi_sei": { - "calc": "171 - 5.2*Math.log2(volume) - 0.23*cyclomatic - 16.2*Math.log2(span_sloc) + 50*Math.sin(Math.sqrt(cloc / span_sloc * 2.4))", - "description": "SEI variant of the Maintainability Index — adds a bonus for comment density.", - "direction": "higher_better", - "formula": "171 − 5.2·log₂(volume) − 0.23·cyclomatic − 16.2·log₂(span_sloc) + 50·sin(√(cloc ÷ span_sloc × 2.4))", - "group": "maintainability", - "label": "MI (SEI)", - "name": "Maintainability (SEI)", - "short": "MI SEI", - "value_type": "float" - }, - "n1": { - "description": "Total operators (N₁): every operator occurrence counted with repetition (the η₁ tokens, not deduplicated).", - "direction": "lower_better", - "group": "halstead", - "label": "N₁", - "name": "Total operators", - "short": "N₁", - "value_type": "int" - }, - "n2": { - "description": "Total operands (N₂): every operand occurrence counted with repetition (the η₂ texts, not deduplicated).", - "direction": "lower_better", - "group": "halstead", - "label": "N₂", - "name": "Total operands", - "short": "N₂", - "value_type": "int" - }, - "sloc": { - "description": "Source lines of code — lines with at least one non-whitespace, non-comment character. Blank and comment-only lines are not counted (unlike `loc`, the raw file line count).", - "group": "loc", - "label": "Source", - "name": "Source lines", - "short": "SLOC", - "value_type": "int" - }, - "spaces": { - "description": "Unit count: the source file (1) plus each function / impl / trait / closure space. Feeds `cyclomatic`.", - "direction": "lower_better", - "group": "complexity", - "label": "Spaces", - "name": "Code units", - "short": "Spaces", - "value_type": "int" - }, - "span_sloc": { - "description": "Line span of the unit (end_row − start_row) — the size input the Maintainability Index (`mi` / `mi_sei`) is computed from.", - "direction": "lower_better", - "group": "maintainability", - "label": "Span", - "name": "Line span", - "short": "Span", - "value_type": "int" - }, - "time": { - "calc": "effort / 18", - "description": "Estimated implementation time, in seconds.", - "direction": "lower_better", - "formula": "effort ÷ 18", - "group": "halstead", - "label": "Time", - "name": "Coding time (s)", - "short": "H.time(s)", - "value_type": "float" - }, - "visibility": { - "label": "Visibility", - "value_type": "str" - }, - "vocabulary": { - "calc": "eta1 + eta2", - "description": "Vocabulary — distinct operators + operands.", - "direction": "lower_better", - "formula": "eta1 + eta2", - "group": "halstead", - "label": "Vocabulary", - "name": "Distinct symbols", - "short": "H.vocab", - "value_type": "float" - }, - "volume": { - "calc": "length * Math.log2(vocabulary)", - "description": "Algorithm size in bits, from distinct operators and operands.", - "direction": "lower_better", - "formula": "length × log₂(vocabulary)", - "group": "halstead", - "label": "Volume", - "name": "Code volume", - "short": "H.vol", - "value_type": "float" - } - }, - "node_kinds": { - "external": { - "external": true, - "fill": "#f6e2c0", - "label": "Library", - "plural": "Libraries", - "stroke": "#b3801f" - }, - "file": { - "fill": "#dbe9f4", - "label": "File", - "plural": "Files", - "stroke": "#4d6f9c" - } - }, - "nodes": [ - { - "external": true, - "id": "ext:chalk", - "kind": "external", - "name": "chalk" - }, - { - "external": true, - "id": "ext:lodash", - "kind": "external", - "name": "lodash" - }, - { - "blank": 3, - "bugs": 0.074, - "cloc": 8, - "cycle": "chain", - "cyclomatic": 3, - "effort": 3311.938, - "eta1": 17, - "eta2": 17, - "exits": 2, - "fan_in": 2, - "fan_out": 2, - "fan_out_external": 2, - "hk": 176, - "id": "{target}/src/a.js", - "kind": "file", - "length": 62, - "lloc": 10, - "loc": 23, - "mi": 90.314, - "mi_sei": 95.107, - "n1": 41, - "n2": 21, - "name": "a.js", - "sloc": 11, - "spaces": 3, - "span_sloc": 22, - "time": 183.996, - "visibility": "public", - "vocabulary": 34, - "volume": 315.422 - }, - { - "blank": 1, - "bugs": 0.0172, - "cloc": 2, - "closures": 1, - "cyclomatic": 2, - "effort": 373.333, - "eta1": 7, - "eta2": 9, - "fan_out": 1, - "id": "{target}/src/a.test.js", - "kind": "file", - "length": 24, - "lloc": 4, - "loc": 8, - "mi": 115.281, - "mi_sei": 127.65, - "n1": 14, - "n2": 10, - "name": "a.test.js", - "sloc": 4, - "spaces": 2, - "span_sloc": 7, - "time": 20.74, - "visibility": "public", - "vocabulary": 16, - "volume": 96 - }, - { - "blank": 3, - "bugs": 0.0214, - "cloc": 3, - "cycle": "chain", - "cyclomatic": 3, - "effort": 517.942, - "eta1": 8, - "eta2": 7, - "exits": 2, - "fan_in": 2, - "fan_out": 2, - "hk": 128, - "id": "{target}/src/b.js", - "kind": "file", - "length": 29, - "lloc": 8, - "loc": 15, - "mi": 102.961, - "mi_sei": 106.007, - "n1": 21, - "n2": 8, - "name": "b.js", - "sloc": 8, - "spaces": 3, - "span_sloc": 14, - "time": 28.774, - "visibility": "public", - "vocabulary": 15, - "volume": 113.299 - }, - { - "args": 2, - "blank": 4, - "bugs": 0.0508, - "cloc": 6, - "cycle": "chain", - "cyclomatic": 4, - "effort": 1881.938, - "eta1": 11, - "eta2": 17, - "exits": 2, - "fan_in": 2, - "fan_out": 1, - "fan_out_external": 1, - "hk": 36, - "id": "{target}/src/c.js", - "kind": "file", - "length": 55, - "lloc": 5, - "loc": 20, - "mi": 93.377, - "mi_sei": 97.656, - "n1": 33, - "n2": 22, - "name": "c.js", - "sloc": 9, - "spaces": 4, - "span_sloc": 19, - "time": 104.552, - "visibility": "public", - "vocabulary": 28, - "volume": 264.404 - }, - { - "args": 4, - "branches": 4, - "bugs": 0.124, - "cloc": 7, - "cognitive": 7, - "cyclomatic": 7, - "effort": 7175.808, - "eta1": 18, - "eta2": 8, - "exits": 3, - "id": "{target}/src/complex.js", - "kind": "file", - "length": 59, - "lloc": 11, - "loc": 17, - "mi": 95.223, - "mi_sei": 105.117, - "n1": 36, - "n2": 23, - "name": "complex.js", - "sloc": 11, - "spaces": 3, - "span_sloc": 16, - "time": 398.656, - "visibility": "public", - "vocabulary": 26, - "volume": 277.325 - }, - { - "bugs": 0.00329, - "cloc": 3, - "effort": 31.019, - "eta1": 4, - "eta2": 2, - "id": "{target}/src/dynamic.js", - "kind": "file", - "length": 6, - "lloc": 1, - "loc": 5, - "mi": 134.056, - "mi_sei": 166.496, - "n1": 4, - "n2": 2, - "name": "dynamic.js", - "sloc": 1, - "spaces": 1, - "span_sloc": 4, - "time": 1.723, - "visibility": "public", - "vocabulary": 6, - "volume": 15.509 - }, - { - "bugs": 0.0141, - "cloc": 2, - "cycle": "mutual", - "cyclomatic": 2, - "effort": 276.754, - "eta1": 8, - "eta2": 3, - "exits": 1, - "fan_in": 1, - "fan_out": 1, - "hk": 4, - "id": "{target}/src/m1.js", - "kind": "file", - "length": 15, - "lloc": 4, - "loc": 7, - "mi": 120.977, - "mi_sei": 138.029, - "n1": 11, - "n2": 4, - "name": "m1.js", - "sloc": 4, - "spaces": 2, - "span_sloc": 6, - "time": 15.375, - "visibility": "public", - "vocabulary": 11, - "volume": 51.891 - }, - { - "bugs": 0.0141, - "cloc": 1, - "cycle": "mutual", - "cyclomatic": 2, - "effort": 276.754, - "eta1": 8, - "eta2": 3, - "exits": 1, - "fan_in": 1, - "fan_out": 1, - "hk": 4, - "id": "{target}/src/m2.js", - "kind": "file", - "length": 15, - "lloc": 4, - "loc": 6, - "mi": 123.931, - "mi_sei": 135.233, - "n1": 11, - "n2": 4, - "name": "m2.js", - "sloc": 4, - "spaces": 2, - "span_sloc": 5, - "time": 15.375, - "visibility": "public", - "vocabulary": 11, - "volume": 51.891 - } - ], - "stats": { - "blank": 2.75, - "bugs": 0.0398, - "cloc": 4, - "cognitive": 7, - "cyclomatic": 3.285, - "effort": 1730.685, - "fan_in": 1.6, - "fan_out": 1.333, - "hk": 69.6, - "length": 33.125, - "mi": 109.515, - "mi_sei": 121.411, - "sloc": 6.5, - "time": 96.148, - "vocabulary": 18.375, - "volume": 148.217 - }, - "ui": { - "card": [ - "hk", - "sloc" - ], - "columns": [ - "kind", - "cycle", - "sloc", - "hk", - "fan_in", - "fan_out", - "volume", - "bugs", - "effort", - "time", - "length", - "vocabulary", - "cyclomatic", - "cognitive", - "mi", - "mi_sei", - "lloc", - "cloc", - "blank" - ], - "default_sort": "cycle", - "filter": [ - "cycle" - ], - "size": [ - "sloc", - "hk" - ], - "sort": [ - "cycle", - "sloc", - "hk", - "fan_in", - "fan_out", - "volume", - "bugs", - "effort", - "time", - "length", - "vocabulary", - "cyclomatic", - "cognitive", - "mi", - "mi_sei", - "lloc", - "cloc", - "blank" - ], - "summary": [ - "sloc", - "hk", - "fan_in", - "fan_out", - "volume", - "bugs", - "effort", - "time", - "length", - "vocabulary", - "cyclomatic", - "cognitive", - "mi", - "mi_sei", - "lloc", - "cloc", - "blank" - ] - } - } - }, - "plugin": "javascript", - "principles": [ - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/typescript/CPX.md", - "id": "CPX", - "label": "CPX", - "prompt": "These modules are too complex and I want to reduce their complexity.\nReduce it by splitting large units into smaller single-responsibility ones,\nextracting repeated patterns into shared helpers, flattening deeply nested\ncontrol flow, and breaking large functions into focused helpers.", - "sort_metric": "cognitive", - "title": "CPX — Reduce Complexity" - }, - { - "connections": [ - "common" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/typescript/ADP.md", - "id": "ADP", - "label": "ADP", - "prompt": "The dependency graph between modules must form a DAG. When module A depends\non module B, no chain of dependencies should bring B back to A.\n\nIdentify any cycles in the modules below. For each cycle, propose a concrete\nrefactoring (extract a shared abstraction, invert a dependency, split a module)\nthat makes the graph acyclic without breaking existing functionality.\n\nWhen splitting a module to break a cycle, the new structure should:\n- Preserve existing API contracts\n- Minimise coupling in the new structure\n- Follow the Single Responsibility Principle\n- Not introduce new dependency cycles", - "sort_metric": "cycle", - "title": "ADP — Acyclic Dependencies Principle" - }, - { - "connections": [ - "in", - "out" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/typescript/SRP.md", - "id": "SRP", - "label": "SRP", - "prompt": "A module should have one reason to change — it should serve one actor\nand encapsulate one coherent set of decisions.\n\nFor each module below, identify whether it has more than one responsibility.\nPropose how to split responsibilities so each module changes for only one reason,\nand specify the new module boundaries.", - "sort_metric": "sloc", - "title": "SRP — Single Responsibility Principle" - }, - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/typescript/OCP.md", - "id": "OCP", - "label": "OCP", - "prompt": "A module should be open for extension but closed for modification: new behaviour\nshould be addable without editing existing, working code.\n\nFor each module below, identify extension points that currently require editing\nexisting code (e.g. growing match/switch/if-else chains). Propose an extension\nmechanism (polymorphism, strategy, plug-in registration) so new cases can be added\nwithout modifying these modules.", - "sort_metric": "cyclomatic", - "title": "OCP — Open/Closed Principle" - }, - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/typescript/LSP.md", - "id": "LSP", - "label": "LSP", - "prompt": "Every implementation of an interface must honour its full contract — return-value\ninvariants, error/exception behaviour, side effects, and resource ownership — not\njust the method signatures. A subtype must be substitutable for its base without\nsurprising callers.\n\nIdentify the interface implementations in the modules below. For each, check it can\nreplace any other implementation of the same interface without breaking callers.\nFlag violations and propose fixes.", - "sort_metric": "hk", - "title": "LSP — Liskov Substitution Principle" - }, - { - "connections": [ - "in" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/typescript/ISP.md", - "id": "ISP", - "label": "ISP", - "prompt": "Clients should not be forced to depend on methods they do not use. Prefer several\nsmall, focused interfaces over one wide interface.\n\nIdentify interfaces in the modules below that are wider than their consumers need.\nPropose how to split them into narrower interfaces so each consumer depends only on\nwhat it actually uses.", - "sort_metric": "items", - "title": "ISP — Interface Segregation Principle" - }, - { - "connections": [ - "common", - "out" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/typescript/DIP.md", - "id": "DIP", - "label": "DIP", - "prompt": "High-level modules should not depend on low-level modules; both should depend on\nabstractions, and abstractions should not depend on details.\n\nFind places in the modules below where a high-level module depends directly on a\nconcrete low-level type. Propose an abstraction (interface) to invert each such\ndependency, and specify where the concrete implementation should be wired in.", - "sort_metric": "fan_out", - "title": "DIP — Dependency Inversion Principle" - }, - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/typescript/DRY.md", - "id": "DRY", - "label": "DRY", - "prompt": "Every piece of knowledge must have a single authoritative representation.\nDRY is about knowledge duplication, not just code duplication.\n\nIdentify concepts, rules, or policies that are duplicated across the modules\nbelow. For each duplication, propose a canonical location and the refactoring\nneeded to consolidate it.", - "sort_metric": "sloc", - "title": "DRY — Don't Repeat Yourself" - }, - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/typescript/KISS.md", - "id": "KISS", - "label": "KISS", - "prompt": "When two designs solve the same problem, prefer the simpler one — fewer\nabstractions, fewer indirection layers, fewer moving parts.\n\nIdentify over-engineered or needlessly complex constructs in the modules below.\nFor each, describe the simpler alternative and estimate the risk of simplifying.", - "sort_metric": "cognitive", - "title": "KISS — Keep It Simple" - }, - { - "connections": [ - "common", - "out" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/typescript/LoD.md", - "id": "LoD", - "label": "LoD", - "prompt": "A method should only call methods on: itself, its direct fields,\nits parameters, and objects it constructs locally.\nAvoid `x.foo().bar().baz()` chains that traverse object graphs.\n\nIdentify method chains or deep field traversals in the modules below that\nviolate LoD. For each, propose a narrow accessor or a facade that exposes only\nwhat the caller needs, reducing coupling.", - "sort_metric": "fan_out", - "title": "Law of Demeter — Principle of Least Knowledge" - }, - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/typescript/MISU.md", - "id": "MISU", - "label": "MISU", - "prompt": "Move correctness from runtime checks into the type system, so invalid states\ncannot be constructed and fail at compile time rather than at runtime.\n\nIdentify data structures or function signatures in the modules below where invalid\nstates are representable at runtime. For each, propose a type-level encoding\n(sum type / enum, newtype, typestate) that makes the invalid state unrepresentable\nby construction.", - "sort_metric": "cyclomatic", - "title": "MISU — Make Invalid States Unrepresentable" - }, - { - "connections": [ - "common" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/typescript/CoI.md", - "id": "CoI", - "label": "CoI", - "prompt": "Build behaviour by composing small, focused pieces rather than through deep\ninheritance hierarchies.\n\nIdentify large types that accumulate behaviour in the modules below. Propose how to\ndecompose them into smaller composable parts, and show how consumers would assemble\nthe behaviour they need.", - "sort_metric": "items", - "title": "CoI — Composition Over Inheritance" - }, - { - "connections": [ - "out" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/typescript/YAGNI.md", - "id": "YAGNI", - "label": "YAGNI", - "prompt": "Build for the problem you have now, not one you imagine you might have later.\nDon't add an abstraction, a generic parameter, or a public API for a hypothetical\nfuture use.\n\nIdentify abstractions, generics, or public APIs in the modules below that were\nadded speculatively. For each, assess whether multiple real callers use it today,\nand propose simplification if not.", - "sort_metric": "sloc", - "title": "YAGNI — You Aren't Gonna Need It" - } - ], - "prompt": { - "cycle_note": "This is **one** dependency cycle; every module in it is listed below so the whole loop is visible. Fix one cycle at a time — `--top 2`+ lists several separate cycles at once and obscures how each one connects.", - "doc_note": "**First, before reading the source**, run `code-ranker docs {id}` — it prints the full principle and, for your language, the usual cause of this exact violation and the smallest correct fix, often with a worked example and how to confirm it. Read it first: it normally names the remedy outright, so you apply it instead of re-deriving the mechanism from the code.", - "focus": "**Focus the research and report primarily on the modules below.**", - "intro": "I want to apply this to some modules in my system.", - "task": [ - "- Prepare a precise, detailed estimate and a report of where the modules below violate it.", - "- If you find more serious violations elsewhere during research, mention them in the report too.", - "- Show a summary of the report in chat.", - "- If any violation is found, suggest saving the report to a file as a plan for a detailed review, named `.code-ranker/-{id}.md`." - ] - }, - "roots": { - "target": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/javascript/tests/sample" - }, - "schema_version": "4.0", - "target": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/javascript/tests/sample", - "timings": [ - { - "detail": "10 nodes from 8 files", - "ms": 0, - "stage": "javascript" - }, - { - "detail": "8 nodes annotated", - "ms": 0, - "stage": "complexity" - }, - { - "detail": "nodes=10 edges=11", - "ms": 0, - "stage": "projection" - } - ], - "versions": { - "code-ranker": "4.0.0" - }, - "workspace": "/home/user/code-ranker" -} diff --git a/crates/code-ranker-plugins/src/languages/javascript/config.toml b/crates/code-ranker-plugins/src/languages/js/config.toml similarity index 94% rename from crates/code-ranker-plugins/src/languages/javascript/config.toml rename to crates/code-ranker-plugins/src/languages/js/config.toml index 1bdaa9e1..92bc712c 100644 --- a/crates/code-ranker-plugins/src/languages/javascript/config.toml +++ b/crates/code-ranker-plugins/src/languages/js/config.toml @@ -13,7 +13,7 @@ # Principle-corpus language for doc links (`doc_base` inherited from defaults). # JavaScript shares the TypeScript corpus; `doc_overrides = "*"` routes every # principle to `typescript/` instead of the shared `base/` fallback. -doc_lang = "typescript" +doc_lang = "ts" doc_overrides = "*" # ────────────────────────────────────────────────────────────────────────────── @@ -28,3 +28,5 @@ doc_overrides = "*" extensions = ["js", "jsx", "mjs", "cjs"] detect_markers = ["package.json"] +# Short aliases accepted anywhere a language is named. Unique across languages. +aliases = ["javascript"] diff --git a/crates/code-ranker-plugins/src/languages/javascript/mod.rs b/crates/code-ranker-plugins/src/languages/js/mod.rs similarity index 75% rename from crates/code-ranker-plugins/src/languages/javascript/mod.rs rename to crates/code-ranker-plugins/src/languages/js/mod.rs index 5079bf8b..562741d4 100644 --- a/crates/code-ranker-plugins/src/languages/javascript/mod.rs +++ b/crates/code-ranker-plugins/src/languages/js/mod.rs @@ -38,45 +38,45 @@ static CONFIG: LazyLock = LazyLock::new(|| { // Self-register this plugin (collected by `code_ranker_plugin_api::registry`); no // central list anywhere names a language. inventory::submit! { - code_ranker_plugin_api::PluginRegistration(&JavascriptPlugin) + code_ranker_plugin_api::PluginRegistration(&JsPlugin) } /// The JavaScript language plugin (handles .js / .jsx / .mjs / .cjs). -pub struct JavascriptPlugin; +pub struct JsPlugin; -impl LanguagePlugin for JavascriptPlugin { +impl LanguagePlugin for JsPlugin { fn config(&self) -> toml::Table { CONFIG.clone() } fn name(&self) -> &str { - "javascript" + "js" } - fn detect(&self, workspace: &Path, _input: &PluginInput) -> bool { + fn detect(&self, cfg: &toml::Table, workspace: &Path, _input: &PluginInput) -> bool { // Project-detect marker filenames are DATA: read from `config.toml`'s // `detect_markers` (the walk/detect logic stays in Rust). JS detects on // `package.json`. - crate::config::string_list(&CONFIG, "detect_markers") + crate::config::string_list(cfg, "detect_markers") .iter() .any(|m| detect_with_marker(workspace, m)) } - fn levels(&self) -> Vec { + fn levels(&self, cfg: &toml::Table) -> Vec { vec![ - ecmascript_level("files", &CONFIG), - ecmascript_functions_level(&CONFIG), + ecmascript_level("files", cfg), + ecmascript_functions_level(cfg), ] } - fn analyze(&self, workspace: &Path, input: &PluginInput) -> Result { + fn analyze(&self, cfg: &toml::Table, workspace: &Path, input: &PluginInput) -> Result { // File-collection extensions / import-resolution order are DATA: read // from `config.toml`'s `extensions` (a JS-only project collects and // resolves against the same list). Every JavaScript extension uses the one // `tree-sitter-javascript` grammar, so the grammar selector is a constant // (a grammar TYPE can't be config) — the `extensions` list alone gates // which files reach it, so no extension is enumerated here. - let exts = crate::config::string_list(&CONFIG, "extensions"); + let exts = crate::config::string_list(cfg, "extensions"); let exts: Vec<&str> = exts.iter().map(String::as_str).collect(); analyze_ecmascript( workspace, @@ -88,35 +88,39 @@ impl LanguagePlugin for JavascriptPlugin { ) } - fn metrics(&self, graph: &Graph) -> Vec<(String, MetricInputs)> { + fn metrics(&self, _cfg: &toml::Table, graph: &Graph) -> Vec<(String, MetricInputs)> { ecmascript_metrics(graph, |_ext| { Some((tree_sitter_javascript::LANGUAGE.into(), false)) }) } - fn function_units(&self, graph: &Graph) -> Vec<(Node, MetricInputs)> { + fn function_units(&self, _cfg: &toml::Table, graph: &Graph) -> Vec<(Node, MetricInputs)> { ecmascript_function_units(graph, |_ext| { Some((tree_sitter_javascript::LANGUAGE.into(), false)) }) } - fn principles(&self, _input: &PluginInput) -> Vec { + fn principles(&self, cfg: &toml::Table, _input: &PluginInput) -> Vec { // The common catalog from `defaults.toml`, with `doc_url` resolved to // `{doc_base}/typescript/.md` (JS shares the TS principle corpus). - crate::config::resolved_principles(&CONFIG) + crate::config::resolved_principles(cfg) } - fn report_overrides(&self) -> code_ranker_plugin_api::report::ReportOverride { - code_ranker_plugin_api::list_override::report_override(&CONFIG) + fn report_overrides( + &self, + cfg: &toml::Table, + ) -> code_ranker_plugin_api::report::ReportOverride { + code_ranker_plugin_api::list_override::report_override(cfg) } fn metric_specs( &self, + cfg: &toml::Table, defaults: BTreeMap, ) -> BTreeMap { // Shared ECMAScript Halstead operator/operand descriptions (JS and TS use // the same `[halstead]` vocab → one home in `ecmascript/config.toml`). - ecmascript_metric_specs(defaults) + ecmascript_metric_specs(defaults, cfg) } } diff --git a/crates/code-ranker-plugins/src/languages/javascript/tests/mod_rs.rs b/crates/code-ranker-plugins/src/languages/js/tests/mod_rs.rs similarity index 83% rename from crates/code-ranker-plugins/src/languages/javascript/tests/mod_rs.rs rename to crates/code-ranker-plugins/src/languages/js/tests/mod_rs.rs index 6681a416..9b4c9b51 100644 --- a/crates/code-ranker-plugins/src/languages/javascript/tests/mod_rs.rs +++ b/crates/code-ranker-plugins/src/languages/js/tests/mod_rs.rs @@ -5,21 +5,23 @@ use tempfile::TempDir; #[test] fn plugin_name_is_javascript() { - assert_eq!(JavascriptPlugin.name(), "javascript"); + assert_eq!(JsPlugin.name(), "js"); } #[test] fn detect_requires_package_json() { let tmp = TempDir::new().unwrap(); let input = PluginInput::default(); - assert!(!JavascriptPlugin.detect(tmp.path(), &input)); + let cfg = JsPlugin.config(); + assert!(!JsPlugin.detect(&cfg, tmp.path(), &input)); fs::write(tmp.path().join("package.json"), "{}").unwrap(); - assert!(JavascriptPlugin.detect(tmp.path(), &input)); + assert!(JsPlugin.detect(&cfg, tmp.path(), &input)); } #[test] fn levels_returns_files_and_functions() { - let levels = JavascriptPlugin.levels(); + let cfg = JsPlugin.config(); + let levels = JsPlugin.levels(&cfg); assert_eq!(levels.len(), 2); assert_eq!(levels[0].name, "files"); assert!(levels[0].edge_kinds.contains_key("uses")); @@ -46,8 +48,9 @@ fn function_units_extracts_per_function_nodes() { }], edges: vec![], }; - let units: Vec<_> = JavascriptPlugin - .function_units(&graph) + let cfg = JsPlugin.config(); + let units: Vec<_> = JsPlugin + .function_units(&cfg, &graph) .into_iter() .map(|(n, _)| n) .collect(); @@ -74,8 +77,9 @@ fn analyze_builds_js_graph_with_imports_and_externals() { ); write_file(root, "src/b.js", "export function greet() { return 1; }\n"); - let graph = JavascriptPlugin - .analyze(root, &PluginInput::default()) + let cfg = JsPlugin.config(); + let graph = JsPlugin + .analyze(&cfg, root, &PluginInput::default()) .expect("analyze should succeed"); let a_id = root.join("src/a.js").to_string_lossy().into_owned(); @@ -101,11 +105,12 @@ fn metrics_annotates_file_nodes() { "src/a.js", "export function f(x) { if (x > 0) { return 1; } return 2; }\n", ); - let graph = JavascriptPlugin - .analyze(root, &PluginInput::default()) + let cfg = JsPlugin.config(); + let graph = JsPlugin + .analyze(&cfg, root, &PluginInput::default()) .expect("analyze should succeed"); // The plugin measures inputs; the orchestrator (here, the test) writes them. - let inputs = JavascriptPlugin.metrics(&graph); + let inputs = JsPlugin.metrics(&cfg, &graph); assert_eq!(inputs.len(), 1, "the single .js file node is measured"); let a_id = root.join("src/a.js").to_string_lossy().into_owned(); @@ -135,6 +140,7 @@ fn metrics_skip_unreadable_and_unsupported_files() { nodes: vec![n("/no/such/missing.js"), n("/x/readme.txt")], edges: vec![], }; - assert!(JavascriptPlugin.metrics(&graph).is_empty()); - assert!(JavascriptPlugin.function_units(&graph).is_empty()); + let cfg = JsPlugin.config(); + assert!(JsPlugin.metrics(&cfg, &graph).is_empty()); + assert!(JsPlugin.function_units(&cfg, &graph).is_empty()); } diff --git a/crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker-check.codequality.json b/crates/code-ranker-plugins/src/languages/js/tests/sample/code-ranker-check.codequality.json similarity index 83% rename from crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker-check.codequality.json rename to crates/code-ranker-plugins/src/languages/js/tests/sample/code-ranker-check.codequality.json index e78050ca..4267da94 100644 --- a/crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker-check.codequality.json +++ b/crates/code-ranker-plugins/src/languages/js/tests/sample/code-ranker-check.codequality.json @@ -2,7 +2,7 @@ { "check_name": "cycle.chain", "description": "{target}/src/a.js: chain cycle: {target}/src/a.js → {target}/src/b.js → {target}/src/c.js → (back to start)", - "fingerprint": "cycle.chain:{target}/src/a.js", + "fingerprint": "js:cycle.chain:{target}/src/a.js", "location": { "lines": { "begin": 5 @@ -14,7 +14,7 @@ { "check_name": "cycle.mutual", "description": "{target}/src/m1.js: mutual cycle between {target}/src/m1.js ↔ {target}/src/m2.js", - "fingerprint": "cycle.mutual:{target}/src/m1.js", + "fingerprint": "js:cycle.mutual:{target}/src/m1.js", "location": { "lines": { "begin": 3 diff --git a/crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker-check.sarif b/crates/code-ranker-plugins/src/languages/js/tests/sample/code-ranker-check.sarif similarity index 93% rename from crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker-check.sarif rename to crates/code-ranker-plugins/src/languages/js/tests/sample/code-ranker-check.sarif index 0a4b1547..d18674dc 100644 --- a/crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker-check.sarif +++ b/crates/code-ranker-plugins/src/languages/js/tests/sample/code-ranker-check.sarif @@ -21,7 +21,7 @@ "text": "{target}/src/a.js: chain cycle: {target}/src/a.js → {target}/src/b.js → {target}/src/c.js → (back to start)" }, "partialFingerprints": { - "codeRankerRuleLocation/v1": "cycle.chain:{target}/src/a.js" + "codeRankerRuleLocation/v1": "js:cycle.chain:{target}/src/a.js" }, "properties": { "graph": "files", @@ -48,7 +48,7 @@ "text": "{target}/src/m1.js: mutual cycle between {target}/src/m1.js ↔ {target}/src/m2.js" }, "partialFingerprints": { - "codeRankerRuleLocation/v1": "cycle.mutual:{target}/src/m1.js" + "codeRankerRuleLocation/v1": "js:cycle.mutual:{target}/src/m1.js" }, "properties": { "graph": "files", @@ -90,7 +90,7 @@ } } ], - "version": "3.0.0-alpha.1" + "version": "5.0.0" } } } diff --git a/crates/code-ranker-plugins/src/languages/js/tests/sample/code-ranker-report.json b/crates/code-ranker-plugins/src/languages/js/tests/sample/code-ranker-report.json new file mode 100644 index 00000000..b1b60a4b --- /dev/null +++ b/crates/code-ranker-plugins/src/languages/js/tests/sample/code-ranker-report.json @@ -0,0 +1,968 @@ +{ + "command": "code-ranker report /home/user/code-ranker/crates/code-ranker-plugins/src/languages/js/tests/sample --config /home/user/code-ranker/crates/code-ranker-plugins/src/languages/js/tests/sample/code-ranker.toml --output.json.path=/home/user/code-ranker/crates/code-ranker-plugins/src/languages/js/tests/sample/code-ranker-report.json", + "config_file": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/js/tests/sample/code-ranker.toml", + "generated_at": "1970-01-01T00:00:00Z", + "git": { + "branch": "main", + "commit": "000000000000", + "dirty_files": 0, + "origin": "git@example.com:org/repo.git" + }, + "languages": { + "js": { + "graphs": { + "files": { + "attribute_groups": { + "complexity": { + "description": "per-function branching, nesting & size", + "label": "Complexity" + }, + "coupling": { + "description": "how tightly modules depend on each other", + "label": "Coupling" + }, + "halstead": { + "description": "operator/operand vocabulary & derived effort", + "label": "Halstead" + }, + "loc": { + "description": "physical line counts", + "label": "Lines of Code" + }, + "maintainability": { + "description": "composite score", + "label": "Maintainability" + } + }, + "cycle_kinds": { + "chain": { + "description": "Three or more units form a strongly-connected component (A → B → C → A); the whole component must be loaded and changed together, defeating modular boundaries.", + "label": "Chain", + "remediation": "Run `code-ranker docs ADP` and follow its instructions." + }, + "mutual": { + "description": "Two units import each other (A ↔ B), so neither can be built, tested, or understood in isolation — the tightest possible coupling.", + "label": "Mutual", + "remediation": "Run `code-ranker docs ADP` and follow its instructions." + } + }, + "cycles": [ + { + "kind": "mutual", + "nodes": [ + "{target}/src/m1.js", + "{target}/src/m2.js" + ] + }, + { + "kind": "chain", + "nodes": [ + "{target}/src/a.js", + "{target}/src/b.js", + "{target}/src/c.js" + ] + } + ], + "edge_attributes": {}, + "edge_kinds": { + "uses": { + "description": "Import dependency — this file imports from the other.", + "flow": true, + "label": "uses" + } + }, + "edges": [ + { + "kind": "uses", + "line": 11, + "source": "{target}/src/a.js", + "target": "ext:chalk" + }, + { + "kind": "uses", + "line": 9, + "source": "{target}/src/a.js", + "target": "ext:lodash" + }, + { + "kind": "uses", + "line": 5, + "source": "{target}/src/a.js", + "target": "{target}/src/b.js" + }, + { + "kind": "uses", + "line": 7, + "source": "{target}/src/a.js", + "target": "{target}/src/c.js" + }, + { + "kind": "uses", + "line": 3, + "source": "{target}/src/a.test.js", + "target": "{target}/src/a.js" + }, + { + "kind": "uses", + "line": 4, + "source": "{target}/src/b.js", + "target": "{target}/src/a.js" + }, + { + "kind": "uses", + "line": 6, + "source": "{target}/src/b.js", + "target": "{target}/src/c.js" + }, + { + "kind": "uses", + "line": 7, + "source": "{target}/src/c.js", + "target": "ext:chalk" + }, + { + "kind": "uses", + "line": 5, + "source": "{target}/src/c.js", + "target": "{target}/src/b.js" + }, + { + "kind": "uses", + "line": 3, + "source": "{target}/src/m1.js", + "target": "{target}/src/m2.js" + }, + { + "kind": "uses", + "line": 2, + "source": "{target}/src/m2.js", + "target": "{target}/src/m1.js" + } + ], + "node_attributes": { + "args": { + "description": "Number of function / closure arguments.", + "direction": "lower_better", + "group": "complexity", + "label": "Args", + "name": "Arguments", + "short": "Args", + "value_type": "int" + }, + "blank": { + "description": "Empty or whitespace-only lines.", + "group": "loc", + "label": "Blank", + "name": "Blank lines", + "short": "Blank", + "value_type": "int" + }, + "branches": { + "description": "Decision points: if / for / while / loop / match arm / try / && / ||. Feeds `cyclomatic`.", + "direction": "lower_better", + "group": "complexity", + "label": "Branches", + "name": "Decision points", + "short": "Branches", + "value_type": "int" + }, + "bugs": { + "calc": "effort ** (2/3) / 3000", + "description": "Estimated delivered bugs — a rough predictor of defect density.", + "direction": "lower_better", + "formula": "effort^⅔ ÷ 3000", + "group": "halstead", + "label": "Bugs", + "name": "Estimated bugs", + "short": "H.bugs", + "value_type": "float" + }, + "cloc": { + "description": "Comment-only lines (inline comments on code lines are not counted).", + "group": "loc", + "label": "Comments", + "name": "Comment lines", + "short": "Comments", + "value_type": "int" + }, + "closures": { + "description": "Number of closures defined in the unit.", + "direction": "lower_better", + "group": "complexity", + "label": "Closures", + "name": "Closures defined", + "short": "Closures", + "value_type": "int" + }, + "cognitive": { + "description": "How hard the code is for a human to follow — not just how many paths it has.
Like `cyclomatic` it adds +1 for each break in linear flow (`if`, `else`, `match`, loops, `catch`, chained `&&` / `||`), but it also adds an extra +1 for every level of nesting: an `if` inside a loop inside an `if` costs far more than three flat `if`s.
That nesting penalty is the point — deeply indented logic is what actually strains a reader, so a high `cognitive` next to a modest `cyclomatic` flags tangled, hard-to-read code.
Summed across every function in the file.", + "direction": "lower_better", + "group": "complexity", + "label": "Cognitive", + "name": "Cognitive complexity", + "short": "Cognitive", + "value_type": "int" + }, + "cycle": { + "description": "Cycle kind this node participates in.", + "group": "coupling", + "label": "Cycle", + "name": "Dependency cycle", + "short": "Cycle", + "value_type": "str" + }, + "cyclomatic": { + "calc": "spaces + branches", + "description": "Number of independent paths through the code — roughly the minimum number of test cases needed to cover every branch.
A function starts at 1 and gains +1 per decision point: each `if` / `else if`, every `match` / `switch` arm, every loop, and each `&&` / `||` in a condition.
Summed across every function in the file, so it grows with both size and branching — the file's total branching burden.
Counts paths only, ignoring how deeply they nest. For a readability-weighted view see `cognitive`.", + "direction": "lower_better", + "formula": "spaces + branches", + "group": "complexity", + "label": "Cyclomatic", + "name": "Cyclomatic complexity", + "omit_at": 1.0, + "short": "Cyclomatic", + "value_type": "int" + }, + "effort": { + "calc": "(eta1 / 2) * (n2 / eta2) * volume", + "description": "Mental effort to implement the algorithm.", + "direction": "lower_better", + "formula": "(eta1 ÷ 2) × (n2 ÷ eta2) × volume", + "group": "halstead", + "label": "Effort", + "name": "Implementation effort", + "short": "H.effort", + "value_type": "float" + }, + "eta1": { + "description": "Distinct operators (η₁): the count of unique operator token kinds. JavaScript / TypeScript count the keywords `export import from as extends new function let var const return delete throw break continue if else switch case default for in of while try catch finally with async await yield`, arithmetic / logical / comparison / assignment operators (`+ - * / % ** ++ -- && || ! == === != !== < <= > >= = += -= *= /= %= **= ?? ?`), bitwise operators (`& | ^ << >> >>> ~`), and punctuation / delimiters (`. , : ; ( [ { @`).", + "direction": "lower_better", + "group": "halstead", + "label": "η₁", + "name": "Unique operators", + "short": "η₁", + "value_type": "int" + }, + "eta2": { + "description": "Distinct operands (η₂): the count of unique operand texts. JavaScript / TypeScript count identifiers (including `member_expression` / `property_identifier` / `nested_identifier`), literals (`string`, `number`, `true`, `false`, `null`, `undefined`, `void`), `this` / `super`, and the contextual keywords `set` / `get` / `typeof` / `instanceof`.", + "direction": "lower_better", + "group": "halstead", + "label": "η₂", + "name": "Unique operands", + "short": "η₂", + "value_type": "int" + }, + "exits": { + "description": "Number of exit points (return/throw) in the unit.", + "direction": "lower_better", + "group": "complexity", + "label": "Exits", + "name": "Exit points", + "short": "Exits", + "value_type": "int" + }, + "external": { + "label": "External", + "value_type": "bool" + }, + "fan_in": { + "description": "Many other units depend on this one, making it risky to change and a single point of failure — though some hubs (shared types) carry high fan-in legitimately.", + "group": "coupling", + "label": "Fan-in", + "name": "Incoming dependencies", + "short": "Fan-in", + "value_type": "int" + }, + "fan_out": { + "description": "This unit depends on many others, so it breaks when any of them change and is hard to test in isolation.", + "group": "coupling", + "label": "Fan-out", + "name": "Outgoing dependencies", + "short": "Fan-out", + "value_type": "int" + }, + "fan_out_external": { + "description": "Number of distinct external libraries this node depends on.", + "group": "coupling", + "label": "Fan-out (external)", + "name": "External dependencies", + "short": "Fan-out (external)", + "value_type": "int" + }, + "hk": { + "abbreviate": true, + "calc": "sloc * (fan_in * fan_out) ** 2", + "description": "Henry-Kafura information-flow complexity: a module that is both a busy crossroads (high fan-in × fan-out) and large — the most expensive place in the codebase to change.", + "direction": "lower_better", + "formula": "sloc × (fan_in × fan_out)²", + "group": "coupling", + "label": "HK", + "name": "God-object risk", + "short": "HK", + "value_type": "float" + }, + "length": { + "calc": "n1 + n2", + "description": "Program length — total operator + operand occurrences.", + "direction": "lower_better", + "formula": "n1 + n2", + "group": "halstead", + "label": "Length", + "name": "Total tokens", + "short": "H.len", + "value_type": "float" + }, + "lloc": { + "description": "Logical lines — counts statements, not physical lines.", + "group": "loc", + "label": "Logical", + "name": "Logical lines", + "short": "Logical", + "value_type": "int" + }, + "loc": { + "description": "Raw file line count, including blank and comment lines (unlike `sloc`). Large files tend to hold several responsibilities and are harder to review, test, and reuse.", + "group": "loc", + "label": "Lines", + "name": "Total lines", + "remediation": "Split by responsibility into smaller units, extract helpers, and separate data definitions from behavior. For an average breach, break up the largest units first (--top).", + "value_type": "int" + }, + "mi": { + "calc": "171 - 5.2*Math.log(volume) - 0.23*cyclomatic - 16.2*Math.log(span_sloc)", + "description": "Maintainability Index (0–100, higher is more maintainable). Derived from Halstead volume, cyclomatic complexity, and SLOC.", + "direction": "higher_better", + "formula": "171 − 5.2·ln(volume) − 0.23·cyclomatic − 16.2·ln(span_sloc)", + "group": "maintainability", + "label": "MI", + "name": "Maintainability index", + "short": "MI", + "value_type": "float" + }, + "mi_sei": { + "calc": "171 - 5.2*Math.log2(volume) - 0.23*cyclomatic - 16.2*Math.log2(span_sloc) + 50*Math.sin(Math.sqrt(cloc / span_sloc * 2.4))", + "description": "SEI variant of the Maintainability Index — adds a bonus for comment density.", + "direction": "higher_better", + "formula": "171 − 5.2·log₂(volume) − 0.23·cyclomatic − 16.2·log₂(span_sloc) + 50·sin(√(cloc ÷ span_sloc × 2.4))", + "group": "maintainability", + "label": "MI (SEI)", + "name": "Maintainability (SEI)", + "short": "MI SEI", + "value_type": "float" + }, + "n1": { + "description": "Total operators (N₁): every operator occurrence counted with repetition (the η₁ tokens, not deduplicated).", + "direction": "lower_better", + "group": "halstead", + "label": "N₁", + "name": "Total operators", + "short": "N₁", + "value_type": "int" + }, + "n2": { + "description": "Total operands (N₂): every operand occurrence counted with repetition (the η₂ texts, not deduplicated).", + "direction": "lower_better", + "group": "halstead", + "label": "N₂", + "name": "Total operands", + "short": "N₂", + "value_type": "int" + }, + "sloc": { + "description": "Source lines of code — lines with at least one non-whitespace, non-comment character. Blank and comment-only lines are not counted (unlike `loc`, the raw file line count).", + "group": "loc", + "label": "Source", + "name": "Source lines", + "short": "SLOC", + "value_type": "int" + }, + "spaces": { + "description": "Unit count: the source file (1) plus each function / impl / trait / closure space. Feeds `cyclomatic`.", + "direction": "lower_better", + "group": "complexity", + "label": "Spaces", + "name": "Code units", + "short": "Spaces", + "value_type": "int" + }, + "span_sloc": { + "description": "Line span of the unit (end_row − start_row) — the size input the Maintainability Index (`mi` / `mi_sei`) is computed from.", + "direction": "lower_better", + "group": "maintainability", + "label": "Span", + "name": "Line span", + "short": "Span", + "value_type": "int" + }, + "time": { + "calc": "effort / 18", + "description": "Estimated implementation time, in seconds.", + "direction": "lower_better", + "formula": "effort ÷ 18", + "group": "halstead", + "label": "Time", + "name": "Coding time (s)", + "short": "H.time(s)", + "value_type": "float" + }, + "visibility": { + "label": "Visibility", + "value_type": "str" + }, + "vocabulary": { + "calc": "eta1 + eta2", + "description": "Vocabulary — distinct operators + operands.", + "direction": "lower_better", + "formula": "eta1 + eta2", + "group": "halstead", + "label": "Vocabulary", + "name": "Distinct symbols", + "short": "H.vocab", + "value_type": "float" + }, + "volume": { + "calc": "length * Math.log2(vocabulary)", + "description": "Algorithm size in bits, from distinct operators and operands.", + "direction": "lower_better", + "formula": "length × log₂(vocabulary)", + "group": "halstead", + "label": "Volume", + "name": "Code volume", + "short": "H.vol", + "value_type": "float" + } + }, + "node_kinds": { + "external": { + "external": true, + "fill": "#f6e2c0", + "label": "Library", + "plural": "Libraries", + "stroke": "#b3801f" + }, + "file": { + "fill": "#dbe9f4", + "label": "File", + "plural": "Files", + "stroke": "#4d6f9c" + } + }, + "nodes": [ + { + "external": true, + "id": "ext:chalk", + "kind": "external", + "name": "chalk" + }, + { + "external": true, + "id": "ext:lodash", + "kind": "external", + "name": "lodash" + }, + { + "blank": 3, + "bugs": 0.074, + "cloc": 8, + "cycle": "chain", + "cyclomatic": 3, + "effort": 3311.938, + "eta1": 17, + "eta2": 17, + "exits": 2, + "fan_in": 2, + "fan_out": 2, + "fan_out_external": 2, + "hk": 176, + "id": "{target}/src/a.js", + "kind": "file", + "length": 62, + "lloc": 10, + "loc": 23, + "mi": 90.314, + "mi_sei": 95.107, + "n1": 41, + "n2": 21, + "name": "a.js", + "sloc": 11, + "spaces": 3, + "span_sloc": 22, + "time": 183.996, + "visibility": "public", + "vocabulary": 34, + "volume": 315.422 + }, + { + "blank": 1, + "bugs": 0.0172, + "cloc": 2, + "closures": 1, + "cyclomatic": 2, + "effort": 373.333, + "eta1": 7, + "eta2": 9, + "fan_out": 1, + "id": "{target}/src/a.test.js", + "kind": "file", + "length": 24, + "lloc": 4, + "loc": 8, + "mi": 115.281, + "mi_sei": 127.65, + "n1": 14, + "n2": 10, + "name": "a.test.js", + "sloc": 4, + "spaces": 2, + "span_sloc": 7, + "time": 20.74, + "visibility": "public", + "vocabulary": 16, + "volume": 96 + }, + { + "blank": 3, + "bugs": 0.0214, + "cloc": 3, + "cycle": "chain", + "cyclomatic": 3, + "effort": 517.942, + "eta1": 8, + "eta2": 7, + "exits": 2, + "fan_in": 2, + "fan_out": 2, + "hk": 128, + "id": "{target}/src/b.js", + "kind": "file", + "length": 29, + "lloc": 8, + "loc": 15, + "mi": 102.961, + "mi_sei": 106.007, + "n1": 21, + "n2": 8, + "name": "b.js", + "sloc": 8, + "spaces": 3, + "span_sloc": 14, + "time": 28.774, + "visibility": "public", + "vocabulary": 15, + "volume": 113.299 + }, + { + "args": 2, + "blank": 4, + "bugs": 0.0508, + "cloc": 6, + "cycle": "chain", + "cyclomatic": 4, + "effort": 1881.938, + "eta1": 11, + "eta2": 17, + "exits": 2, + "fan_in": 2, + "fan_out": 1, + "fan_out_external": 1, + "hk": 36, + "id": "{target}/src/c.js", + "kind": "file", + "length": 55, + "lloc": 5, + "loc": 20, + "mi": 93.377, + "mi_sei": 97.656, + "n1": 33, + "n2": 22, + "name": "c.js", + "sloc": 9, + "spaces": 4, + "span_sloc": 19, + "time": 104.552, + "visibility": "public", + "vocabulary": 28, + "volume": 264.404 + }, + { + "args": 4, + "branches": 4, + "bugs": 0.124, + "cloc": 7, + "cognitive": 7, + "cyclomatic": 7, + "effort": 7175.808, + "eta1": 18, + "eta2": 8, + "exits": 3, + "id": "{target}/src/complex.js", + "kind": "file", + "length": 59, + "lloc": 11, + "loc": 17, + "mi": 95.223, + "mi_sei": 105.117, + "n1": 36, + "n2": 23, + "name": "complex.js", + "sloc": 11, + "spaces": 3, + "span_sloc": 16, + "time": 398.656, + "visibility": "public", + "vocabulary": 26, + "volume": 277.325 + }, + { + "bugs": 0.00329, + "cloc": 3, + "effort": 31.019, + "eta1": 4, + "eta2": 2, + "id": "{target}/src/dynamic.js", + "kind": "file", + "length": 6, + "lloc": 1, + "loc": 5, + "mi": 134.056, + "mi_sei": 166.496, + "n1": 4, + "n2": 2, + "name": "dynamic.js", + "sloc": 1, + "spaces": 1, + "span_sloc": 4, + "time": 1.723, + "visibility": "public", + "vocabulary": 6, + "volume": 15.509 + }, + { + "bugs": 0.0141, + "cloc": 2, + "cycle": "mutual", + "cyclomatic": 2, + "effort": 276.754, + "eta1": 8, + "eta2": 3, + "exits": 1, + "fan_in": 1, + "fan_out": 1, + "hk": 4, + "id": "{target}/src/m1.js", + "kind": "file", + "length": 15, + "lloc": 4, + "loc": 7, + "mi": 120.977, + "mi_sei": 138.029, + "n1": 11, + "n2": 4, + "name": "m1.js", + "sloc": 4, + "spaces": 2, + "span_sloc": 6, + "time": 15.375, + "visibility": "public", + "vocabulary": 11, + "volume": 51.891 + }, + { + "bugs": 0.0141, + "cloc": 1, + "cycle": "mutual", + "cyclomatic": 2, + "effort": 276.754, + "eta1": 8, + "eta2": 3, + "exits": 1, + "fan_in": 1, + "fan_out": 1, + "hk": 4, + "id": "{target}/src/m2.js", + "kind": "file", + "length": 15, + "lloc": 4, + "loc": 6, + "mi": 123.931, + "mi_sei": 135.233, + "n1": 11, + "n2": 4, + "name": "m2.js", + "sloc": 4, + "spaces": 2, + "span_sloc": 5, + "time": 15.375, + "visibility": "public", + "vocabulary": 11, + "volume": 51.891 + } + ], + "stats": { + "blank": 2.75, + "bugs": 0.0398, + "cloc": 4, + "cognitive": 7, + "cyclomatic": 3.285, + "effort": 1730.685, + "fan_in": 1.6, + "fan_out": 1.333, + "hk": 69.6, + "length": 33.125, + "mi": 109.515, + "mi_sei": 121.411, + "sloc": 6.5, + "time": 96.148, + "vocabulary": 18.375, + "volume": 148.217 + }, + "ui": { + "card": [ + "hk", + "sloc" + ], + "columns": [ + "kind", + "cycle", + "sloc", + "hk", + "fan_in", + "fan_out", + "volume", + "bugs", + "effort", + "time", + "length", + "vocabulary", + "cyclomatic", + "cognitive", + "mi", + "mi_sei", + "lloc", + "cloc", + "blank" + ], + "default_sort": "cycle", + "filter": [ + "cycle" + ], + "size": [ + "sloc", + "hk" + ], + "sort": [ + "cycle", + "sloc", + "hk", + "fan_in", + "fan_out", + "volume", + "bugs", + "effort", + "time", + "length", + "vocabulary", + "cyclomatic", + "cognitive", + "mi", + "mi_sei", + "lloc", + "cloc", + "blank" + ], + "summary": [ + "sloc", + "hk", + "fan_in", + "fan_out", + "volume", + "bugs", + "effort", + "time", + "length", + "vocabulary", + "cyclomatic", + "cognitive", + "mi", + "mi_sei", + "lloc", + "cloc", + "blank" + ] + } + } + }, + "principles": [ + { + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/ts/CPX.md", + "id": "CPX", + "label": "CPX", + "prompt": "These modules are too complex and I want to reduce their complexity.\nReduce it by splitting large units into smaller single-responsibility ones,\nextracting repeated patterns into shared helpers, flattening deeply nested\ncontrol flow, and breaking large functions into focused helpers.", + "sort_metric": "cognitive", + "title": "CPX — Reduce Complexity" + }, + { + "connections": [ + "common" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/ts/ADP.md", + "id": "ADP", + "label": "ADP", + "prompt": "The dependency graph between modules must form a DAG. When module A depends\non module B, no chain of dependencies should bring B back to A.\n\nIdentify any cycles in the modules below. For each cycle, propose a concrete\nrefactoring (extract a shared abstraction, invert a dependency, split a module)\nthat makes the graph acyclic without breaking existing functionality.\n\nWhen splitting a module to break a cycle, the new structure should:\n- Preserve existing API contracts\n- Minimise coupling in the new structure\n- Follow the Single Responsibility Principle\n- Not introduce new dependency cycles", + "sort_metric": "cycle", + "title": "ADP — Acyclic Dependencies Principle" + }, + { + "connections": [ + "in", + "out" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/ts/SRP.md", + "id": "SRP", + "label": "SRP", + "prompt": "A module should have one reason to change — it should serve one actor\nand encapsulate one coherent set of decisions.\n\nFor each module below, identify whether it has more than one responsibility.\nPropose how to split responsibilities so each module changes for only one reason,\nand specify the new module boundaries.", + "sort_metric": "sloc", + "title": "SRP — Single Responsibility Principle" + }, + { + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/ts/OCP.md", + "id": "OCP", + "label": "OCP", + "prompt": "A module should be open for extension but closed for modification: new behaviour\nshould be addable without editing existing, working code.\n\nFor each module below, identify extension points that currently require editing\nexisting code (e.g. growing match/switch/if-else chains). Propose an extension\nmechanism (polymorphism, strategy, plug-in registration) so new cases can be added\nwithout modifying these modules.", + "sort_metric": "cyclomatic", + "title": "OCP — Open/Closed Principle" + }, + { + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/ts/LSP.md", + "id": "LSP", + "label": "LSP", + "prompt": "Every implementation of an interface must honour its full contract — return-value\ninvariants, error/exception behaviour, side effects, and resource ownership — not\njust the method signatures. A subtype must be substitutable for its base without\nsurprising callers.\n\nIdentify the interface implementations in the modules below. For each, check it can\nreplace any other implementation of the same interface without breaking callers.\nFlag violations and propose fixes.", + "sort_metric": "hk", + "title": "LSP — Liskov Substitution Principle" + }, + { + "connections": [ + "in" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/ts/ISP.md", + "id": "ISP", + "label": "ISP", + "prompt": "Clients should not be forced to depend on methods they do not use. Prefer several\nsmall, focused interfaces over one wide interface.\n\nIdentify interfaces in the modules below that are wider than their consumers need.\nPropose how to split them into narrower interfaces so each consumer depends only on\nwhat it actually uses.", + "sort_metric": "items", + "title": "ISP — Interface Segregation Principle" + }, + { + "connections": [ + "common", + "out" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/ts/DIP.md", + "id": "DIP", + "label": "DIP", + "prompt": "High-level modules should not depend on low-level modules; both should depend on\nabstractions, and abstractions should not depend on details.\n\nFind places in the modules below where a high-level module depends directly on a\nconcrete low-level type. Propose an abstraction (interface) to invert each such\ndependency, and specify where the concrete implementation should be wired in.", + "sort_metric": "fan_out", + "title": "DIP — Dependency Inversion Principle" + }, + { + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/ts/DRY.md", + "id": "DRY", + "label": "DRY", + "prompt": "Every piece of knowledge must have a single authoritative representation.\nDRY is about knowledge duplication, not just code duplication.\n\nIdentify concepts, rules, or policies that are duplicated across the modules\nbelow. For each duplication, propose a canonical location and the refactoring\nneeded to consolidate it.", + "sort_metric": "sloc", + "title": "DRY — Don't Repeat Yourself" + }, + { + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/ts/KISS.md", + "id": "KISS", + "label": "KISS", + "prompt": "When two designs solve the same problem, prefer the simpler one — fewer\nabstractions, fewer indirection layers, fewer moving parts.\n\nIdentify over-engineered or needlessly complex constructs in the modules below.\nFor each, describe the simpler alternative and estimate the risk of simplifying.", + "sort_metric": "cognitive", + "title": "KISS — Keep It Simple" + }, + { + "connections": [ + "common", + "out" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/ts/LoD.md", + "id": "LoD", + "label": "LoD", + "prompt": "A method should only call methods on: itself, its direct fields,\nits parameters, and objects it constructs locally.\nAvoid `x.foo().bar().baz()` chains that traverse object graphs.\n\nIdentify method chains or deep field traversals in the modules below that\nviolate LoD. For each, propose a narrow accessor or a facade that exposes only\nwhat the caller needs, reducing coupling.", + "sort_metric": "fan_out", + "title": "Law of Demeter — Principle of Least Knowledge" + }, + { + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/ts/MISU.md", + "id": "MISU", + "label": "MISU", + "prompt": "Move correctness from runtime checks into the type system, so invalid states\ncannot be constructed and fail at compile time rather than at runtime.\n\nIdentify data structures or function signatures in the modules below where invalid\nstates are representable at runtime. For each, propose a type-level encoding\n(sum type / enum, newtype, typestate) that makes the invalid state unrepresentable\nby construction.", + "sort_metric": "cyclomatic", + "title": "MISU — Make Invalid States Unrepresentable" + }, + { + "connections": [ + "common" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/ts/CoI.md", + "id": "CoI", + "label": "CoI", + "prompt": "Build behaviour by composing small, focused pieces rather than through deep\ninheritance hierarchies.\n\nIdentify large types that accumulate behaviour in the modules below. Propose how to\ndecompose them into smaller composable parts, and show how consumers would assemble\nthe behaviour they need.", + "sort_metric": "items", + "title": "CoI — Composition Over Inheritance" + }, + { + "connections": [ + "out" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/ts/YAGNI.md", + "id": "YAGNI", + "label": "YAGNI", + "prompt": "Build for the problem you have now, not one you imagine you might have later.\nDon't add an abstraction, a generic parameter, or a public API for a hypothetical\nfuture use.\n\nIdentify abstractions, generics, or public APIs in the modules below that were\nadded speculatively. For each, assess whether multiple real callers use it today,\nand propose simplification if not.", + "sort_metric": "sloc", + "title": "YAGNI — You Aren't Gonna Need It" + } + ], + "prompt": { + "cycle_note": "This is **one** dependency cycle; every module in it is listed below so the whole loop is visible. Fix one cycle at a time — `--top 2`+ lists several separate cycles at once and obscures how each one connects.", + "doc_note": "**First, before reading the source**, run `code-ranker docs {id}` — it prints the full principle and, for your language, the usual cause of this exact violation and the smallest correct fix, often with a worked example and how to confirm it. Read it first: it normally names the remedy outright, so you apply it instead of re-deriving the mechanism from the code.", + "focus": "**Focus the research and report primarily on the modules below.**", + "intro": "I want to apply this to some modules in my system.", + "task": [ + "- Prepare a precise, detailed estimate and a report of where the modules below violate it.", + "- If you find more serious violations elsewhere during research, mention them in the report too.", + "- Show a summary of the report in chat.", + "- If any violation is found, suggest saving the report to a file as a plan for a detailed review, named `.code-ranker/-{id}.md`." + ] + } + } + }, + "plugins": [ + "js" + ], + "roots": { + "target": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/js/tests/sample" + }, + "schema_version": "5.0", + "target": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/js/tests/sample", + "timings": [ + { + "detail": "10 nodes from 8 files", + "ms": 0, + "stage": "js: parse" + }, + { + "detail": "8 nodes annotated", + "ms": 0, + "stage": "js: complexity" + }, + { + "detail": "nodes=10 edges=11", + "ms": 0, + "stage": "js: projection" + } + ], + "versions": { + "code-ranker": "5.0.0" + }, + "workspace": "/home/user/code-ranker" +} diff --git a/crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker.toml b/crates/code-ranker-plugins/src/languages/js/tests/sample/code-ranker.toml similarity index 79% rename from crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker.toml rename to crates/code-ranker-plugins/src/languages/js/tests/sample/code-ranker.toml index 02be682d..14dca96c 100644 --- a/crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker.toml +++ b/crates/code-ranker-plugins/src/languages/js/tests/sample/code-ranker.toml @@ -1,9 +1,9 @@ -version = "4.0" +version = "5.0" # Self-contained config for the code-ranker "javascript" sample fixture. # Pin the plugin and KEEP test files in the graph (ignore.tests = false) so the # fixture is reproducible regardless of any repo-level config and so that test # files' imports stay visible in the report. -plugin = "javascript" - -[ignore] +[plugins] +enabled = ["javascript"] +[plugins.base.ignore] tests = false diff --git a/crates/code-ranker-plugins/src/languages/javascript/tests/sample/src/a.js b/crates/code-ranker-plugins/src/languages/js/tests/sample/src/a.js similarity index 100% rename from crates/code-ranker-plugins/src/languages/javascript/tests/sample/src/a.js rename to crates/code-ranker-plugins/src/languages/js/tests/sample/src/a.js diff --git a/crates/code-ranker-plugins/src/languages/javascript/tests/sample/src/a.test.js b/crates/code-ranker-plugins/src/languages/js/tests/sample/src/a.test.js similarity index 100% rename from crates/code-ranker-plugins/src/languages/javascript/tests/sample/src/a.test.js rename to crates/code-ranker-plugins/src/languages/js/tests/sample/src/a.test.js diff --git a/crates/code-ranker-plugins/src/languages/javascript/tests/sample/src/b.js b/crates/code-ranker-plugins/src/languages/js/tests/sample/src/b.js similarity index 100% rename from crates/code-ranker-plugins/src/languages/javascript/tests/sample/src/b.js rename to crates/code-ranker-plugins/src/languages/js/tests/sample/src/b.js diff --git a/crates/code-ranker-plugins/src/languages/javascript/tests/sample/src/c.js b/crates/code-ranker-plugins/src/languages/js/tests/sample/src/c.js similarity index 100% rename from crates/code-ranker-plugins/src/languages/javascript/tests/sample/src/c.js rename to crates/code-ranker-plugins/src/languages/js/tests/sample/src/c.js diff --git a/crates/code-ranker-plugins/src/languages/javascript/tests/sample/src/complex.js b/crates/code-ranker-plugins/src/languages/js/tests/sample/src/complex.js similarity index 100% rename from crates/code-ranker-plugins/src/languages/javascript/tests/sample/src/complex.js rename to crates/code-ranker-plugins/src/languages/js/tests/sample/src/complex.js diff --git a/crates/code-ranker-plugins/src/languages/javascript/tests/sample/src/dynamic.js b/crates/code-ranker-plugins/src/languages/js/tests/sample/src/dynamic.js similarity index 100% rename from crates/code-ranker-plugins/src/languages/javascript/tests/sample/src/dynamic.js rename to crates/code-ranker-plugins/src/languages/js/tests/sample/src/dynamic.js diff --git a/crates/code-ranker-plugins/src/languages/javascript/tests/sample/src/m1.js b/crates/code-ranker-plugins/src/languages/js/tests/sample/src/m1.js similarity index 100% rename from crates/code-ranker-plugins/src/languages/javascript/tests/sample/src/m1.js rename to crates/code-ranker-plugins/src/languages/js/tests/sample/src/m1.js diff --git a/crates/code-ranker-plugins/src/languages/javascript/tests/sample/src/m2.js b/crates/code-ranker-plugins/src/languages/js/tests/sample/src/m2.js similarity index 100% rename from crates/code-ranker-plugins/src/languages/javascript/tests/sample/src/m2.js rename to crates/code-ranker-plugins/src/languages/js/tests/sample/src/m2.js diff --git a/crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker-check.codequality.json b/crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker-check.codequality.json deleted file mode 100644 index 37649266..00000000 --- a/crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker-check.codequality.json +++ /dev/null @@ -1,26 +0,0 @@ -[ - { - "check_name": "threshold.file.broken_links", - "description": "{target}/index.md: broken links 1 exceeds limit 0 (inf× over budget)", - "fingerprint": "threshold.file.broken_links:{target}/index.md", - "location": { - "lines": { - "begin": 1 - }, - "path": "index.md" - }, - "severity": "major" - }, - { - "check_name": "cycle.chain", - "description": "{target}/api.md: chain cycle: {target}/api.md → {target}/index.md → {target}/guide.md → (back to start)", - "fingerprint": "cycle.chain:{target}/api.md", - "location": { - "lines": { - "begin": 1 - }, - "path": "api.md" - }, - "severity": "major" - } -] diff --git a/crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker-report.json b/crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker-report.json deleted file mode 100644 index 7a484fa1..00000000 --- a/crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker-report.json +++ /dev/null @@ -1,275 +0,0 @@ -{ - "command": "code-ranker report crates/code-ranker-plugins/src/languages/markdown/tests/sample --config crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker.toml --output.json.path=crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker-report.json --output.mode quiet", - "config_file": "crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker.toml", - "generated_at": "1970-01-01T00:00:00Z", - "git": { - "branch": "main", - "commit": "000000000000", - "dirty_files": 0, - "origin": "git@example.com:org/repo.git" - }, - "graphs": { - "files": { - "attribute_groups": { - "coupling": { - "description": "how tightly modules depend on each other", - "label": "Coupling" - }, - "loc": { - "description": "physical line counts", - "label": "Lines of Code" - } - }, - "cycle_kinds": { - "chain": { - "description": "Three or more units form a strongly-connected component (A → B → C → A); the whole component must be loaded and changed together, defeating modular boundaries.", - "label": "Chain", - "remediation": "Run `code-ranker docs ADP` and follow its instructions." - } - }, - "cycles": [ - { - "kind": "chain", - "nodes": [ - "{target}/api.md", - "{target}/index.md", - "{target}/guide.md" - ] - } - ], - "edge_attributes": {}, - "edge_kinds": { - "uses": { - "description": "Import dependency — this file imports from the other.", - "flow": true, - "label": "uses" - } - }, - "edges": [ - { - "kind": "uses", - "source": "{target}/api.md", - "target": "{target}/guide.md" - }, - { - "kind": "uses", - "source": "{target}/api.md", - "target": "{target}/index.md" - }, - { - "kind": "uses", - "source": "{target}/guide.md", - "target": "{target}/api.md" - }, - { - "kind": "uses", - "source": "{target}/guide.md", - "target": "{target}/index.md" - }, - { - "kind": "uses", - "source": "{target}/index.md", - "target": "{target}/api.md" - }, - { - "kind": "uses", - "source": "{target}/index.md", - "target": "{target}/guide.md" - } - ], - "node_attributes": { - "broken_links": { - "description": "Links to a local file (not a URL or `#anchor`) that does not exist on disk. A documentation-quality signal — gate it to zero in `check`.", - "direction": "lower_better", - "label": "Broken links", - "remediation": "Fix or remove each dead link — correct the path, restore the target file, or drop the reference.", - "short": "Broken", - "thresholds": { - "info": 0.0, - "warning": 0.0 - }, - "value_type": "int" - }, - "cycle": { - "description": "Cycle kind this node participates in.", - "group": "coupling", - "label": "Cycle", - "name": "Dependency cycle", - "short": "Cycle", - "value_type": "str" - }, - "fan_in": { - "description": "Many other units depend on this one, making it risky to change and a single point of failure — though some hubs (shared types) carry high fan-in legitimately.", - "group": "coupling", - "label": "Fan-in", - "name": "Incoming dependencies", - "short": "Fan-in", - "value_type": "int" - }, - "fan_out": { - "description": "This unit depends on many others, so it breaks when any of them change and is hard to test in isolation.", - "group": "coupling", - "label": "Fan-out", - "name": "Outgoing dependencies", - "short": "Fan-out", - "value_type": "int" - }, - "headings": { - "description": "Number of ATX headings (`#` … `######`) — the document's section count.", - "label": "Headings", - "remediation": "Split the document into focused pages, or group sections under fewer top-level headings.", - "short": "H", - "value_type": "int" - }, - "links": { - "description": "Total inline Markdown links `[text](dest)` (local files, URLs and anchors alike).", - "label": "Links", - "remediation": "Group related references, or split the document so each page carries a focused set of links.", - "short": "Links", - "value_type": "int" - }, - "loc": { - "description": "Raw file line count, including blank and comment lines (unlike `sloc`). Large files tend to hold several responsibilities and are harder to review, test, and reuse.", - "group": "loc", - "label": "Lines", - "name": "Total lines", - "remediation": "Split by responsibility into smaller units, extract helpers, and separate data definitions from behavior. For an average breach, break up the largest units first (--top).", - "value_type": "int" - }, - "max_depth": { - "description": "Deepest heading level used (1 = only `#`, 6 = down to `######`). A proxy for how deeply the document is nested.", - "direction": "lower_better", - "label": "Max heading depth", - "remediation": "Flatten the section hierarchy — promote deeply nested headings, or split them into separate documents.", - "short": "H-depth", - "value_type": "int" - } - }, - "node_kinds": { - "file": { - "fill": "#dbe9f4", - "label": "File", - "plural": "Files", - "stroke": "#4d6f9c" - } - }, - "nodes": [ - { - "cycle": "chain", - "fan_in": 2, - "fan_out": 2, - "headings": 1, - "id": "{target}/api.md", - "kind": "file", - "links": 2, - "loc": 4, - "max_depth": 1, - "name": "api.md" - }, - { - "cycle": "chain", - "fan_in": 2, - "fan_out": 2, - "headings": 1, - "id": "{target}/guide.md", - "kind": "file", - "links": 2, - "loc": 4, - "max_depth": 1, - "name": "guide.md" - }, - { - "broken_links": 1, - "cycle": "chain", - "fan_in": 2, - "fan_out": 2, - "headings": 1, - "id": "{target}/index.md", - "kind": "file", - "links": 4, - "loc": 6, - "max_depth": 1, - "name": "index.md" - } - ], - "stats": { - "fan_in": 2, - "fan_out": 2 - }, - "ui": { - "card": [], - "columns": [ - "kind", - "cycle", - "fan_in", - "fan_out", - "headings", - "max_depth", - "links", - "broken_links" - ], - "default_sort": "cycle", - "filter": [ - "cycle" - ], - "size": [], - "sort": [ - "cycle", - "fan_in", - "fan_out", - "headings", - "max_depth", - "links", - "broken_links" - ], - "summary": [ - "fan_in", - "fan_out", - "headings", - "max_depth", - "links", - "broken_links" - ] - } - } - }, - "plugin": "markdown", - "prompt": { - "cycle_note": "This is **one** dependency cycle; every module in it is listed below so the whole loop is visible. Fix one cycle at a time — `--top 2`+ lists several separate cycles at once and obscures how each one connects.", - "doc_note": "**First, before reading the source**, run `code-ranker docs {id}` — it prints the full principle and, for your language, the usual cause of this exact violation and the smallest correct fix, often with a worked example and how to confirm it. Read it first: it normally names the remedy outright, so you apply it instead of re-deriving the mechanism from the code.", - "focus": "**Focus the research and report primarily on the modules below.**", - "intro": "I want to apply this to some modules in my system.", - "task": [ - "- Prepare a precise, detailed estimate and a report of where the modules below violate it.", - "- If you find more serious violations elsewhere during research, mention them in the report too.", - "- Show a summary of the report in chat.", - "- If any violation is found, suggest saving the report to a file as a plan for a detailed review, named `.code-ranker/-{id}.md`." - ] - }, - "roots": { - "target": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/markdown/tests/sample" - }, - "schema_version": "4.0", - "target": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/markdown/tests/sample", - "timings": [ - { - "detail": "3 nodes from 3 files", - "ms": 0, - "stage": "markdown" - }, - { - "detail": "0 nodes annotated", - "ms": 0, - "stage": "complexity" - }, - { - "detail": "nodes=3 edges=6", - "ms": 0, - "stage": "projection" - } - ], - "versions": { - "code-ranker": "4.0.0" - }, - "workspace": "/home/user/code-ranker" -} diff --git a/crates/code-ranker-plugins/src/languages/markdown/config.toml b/crates/code-ranker-plugins/src/languages/md/config.toml similarity index 96% rename from crates/code-ranker-plugins/src/languages/markdown/config.toml rename to crates/code-ranker-plugins/src/languages/md/config.toml index 4ded8601..28cda60e 100644 --- a/crates/code-ranker-plugins/src/languages/markdown/config.toml +++ b/crates/code-ranker-plugins/src/languages/md/config.toml @@ -8,10 +8,12 @@ # No own principle corpus yet: with no `doc_overrides`, every `doc_url` inherits # the shared `base/` corpus. `doc_lang` names the folder a future own corpus would use. -doc_lang = "markdown" +doc_lang = "md" extensions = ["md", "markdown"] skip_dirs = ["node_modules", "target", "vendor"] +# Short aliases accepted anywhere a language is named. Unique across languages. +aliases = ["markdown"] # Multi-char Markdown syntax markers the line scanner keys on — DATA (the scan # LOGIC stays in `structure.rs`; single-char tokens like `#`/`)` stay inline as diff --git a/crates/code-ranker-plugins/src/languages/markdown/mod.rs b/crates/code-ranker-plugins/src/languages/md/mod.rs similarity index 70% rename from crates/code-ranker-plugins/src/languages/markdown/mod.rs rename to crates/code-ranker-plugins/src/languages/md/mod.rs index 2a474174..4ee0e8e1 100644 --- a/crates/code-ranker-plugins/src/languages/markdown/mod.rs +++ b/crates/code-ranker-plugins/src/languages/md/mod.rs @@ -24,30 +24,30 @@ static CONFIG: LazyLock = // Self-register this plugin (collected by `code_ranker_plugin_api::registry`); no // central list anywhere names a language. inventory::submit! { - code_ranker_plugin_api::PluginRegistration(&MarkdownPlugin) + code_ranker_plugin_api::PluginRegistration(&MdPlugin) } /// The Markdown language plugin (registered by the CLI). -pub struct MarkdownPlugin; +pub struct MdPlugin; -impl LanguagePlugin for MarkdownPlugin { +impl LanguagePlugin for MdPlugin { fn config(&self) -> toml::Table { CONFIG.clone() } fn name(&self) -> &str { - "markdown" + "md" } - fn detect(&self, workspace: &Path, input: &PluginInput) -> bool { + fn detect(&self, _cfg: &toml::Table, workspace: &Path, input: &PluginInput) -> bool { structure::detect(workspace, &crate::walk::ignore_from(input)) } - fn levels(&self) -> Vec { + fn levels(&self, cfg: &toml::Table) -> Vec { vec![Level { name: "files".into(), - edge_kinds: crate::config::edge_kinds(&CONFIG), - node_attributes: crate::config::node_attributes(&CONFIG), + edge_kinds: crate::config::edge_kinds(cfg), + node_attributes: crate::config::node_attributes(cfg), edge_attributes: BTreeMap::new(), attribute_groups: BTreeMap::new(), node_kinds: default_node_kinds(), @@ -56,21 +56,24 @@ impl LanguagePlugin for MarkdownPlugin { }] } - fn analyze(&self, workspace: &Path, input: &PluginInput) -> Result { + fn analyze(&self, _cfg: &toml::Table, workspace: &Path, input: &PluginInput) -> Result { structure::analyze(workspace, &crate::walk::ignore_from(input)) } // No `metrics` / `function_units`: Markdown emits only the structural `loc` // (set in `analyze`) plus the orchestrator-derived coupling over the links. - fn principles(&self, _input: &PluginInput) -> Vec { + fn principles(&self, _cfg: &toml::Table, _input: &PluginInput) -> Vec { // The common catalog is a set of code-refactoring lenses — not meaningful // for prose — so Markdown ships none. Vec::new() } - fn report_overrides(&self) -> code_ranker_plugin_api::report::ReportOverride { - code_ranker_plugin_api::list_override::report_override(&CONFIG) + fn report_overrides( + &self, + cfg: &toml::Table, + ) -> code_ranker_plugin_api::report::ReportOverride { + code_ranker_plugin_api::list_override::report_override(cfg) } } diff --git a/crates/code-ranker-plugins/src/languages/markdown/structure.rs b/crates/code-ranker-plugins/src/languages/md/structure.rs similarity index 100% rename from crates/code-ranker-plugins/src/languages/markdown/structure.rs rename to crates/code-ranker-plugins/src/languages/md/structure.rs diff --git a/crates/code-ranker-plugins/src/languages/markdown/tests/mod_rs.rs b/crates/code-ranker-plugins/src/languages/md/tests/mod_rs.rs similarity index 63% rename from crates/code-ranker-plugins/src/languages/markdown/tests/mod_rs.rs rename to crates/code-ranker-plugins/src/languages/md/tests/mod_rs.rs index df872530..b5bc37bb 100644 --- a/crates/code-ranker-plugins/src/languages/markdown/tests/mod_rs.rs +++ b/crates/code-ranker-plugins/src/languages/md/tests/mod_rs.rs @@ -5,20 +5,22 @@ use super::*; #[test] fn detects_by_md_presence_and_has_no_principles() { let d = tempfile::tempdir().unwrap(); - let p = MarkdownPlugin; - assert!(!p.detect(d.path(), &PluginInput::default())); + let p = MdPlugin; + let cfg = p.config(); + assert!(!p.detect(&cfg, d.path(), &PluginInput::default())); std::fs::write(d.path().join("README.md"), "# Hi\n").unwrap(); - assert!(p.detect(d.path(), &PluginInput::default())); - assert_eq!(p.name(), "markdown"); - assert!(p.principles(&PluginInput::default()).is_empty()); + assert!(p.detect(&cfg, d.path(), &PluginInput::default())); + assert_eq!(p.name(), "md"); + assert!(p.principles(&cfg, &PluginInput::default()).is_empty()); } #[test] fn analyze_emits_file_nodes_with_loc() { let d = tempfile::tempdir().unwrap(); std::fs::write(d.path().join("a.md"), "# Title\n\nbody line\n").unwrap(); - let p = MarkdownPlugin; - let g = p.analyze(d.path(), &PluginInput::default()).unwrap(); + let p = MdPlugin; + let cfg = p.config(); + let g = p.analyze(&cfg, d.path(), &PluginInput::default()).unwrap(); let file = g .nodes .iter() diff --git a/crates/code-ranker-plugins/src/languages/markdown/tests/sample/api.md b/crates/code-ranker-plugins/src/languages/md/tests/sample/api.md similarity index 100% rename from crates/code-ranker-plugins/src/languages/markdown/tests/sample/api.md rename to crates/code-ranker-plugins/src/languages/md/tests/sample/api.md diff --git a/crates/code-ranker-plugins/src/languages/md/tests/sample/code-ranker-check.codequality.json b/crates/code-ranker-plugins/src/languages/md/tests/sample/code-ranker-check.codequality.json new file mode 100644 index 00000000..5fefa139 --- /dev/null +++ b/crates/code-ranker-plugins/src/languages/md/tests/sample/code-ranker-check.codequality.json @@ -0,0 +1,14 @@ +[ + { + "check_name": "threshold.file.broken_links", + "description": "{target}/index.md: broken links 1 exceeds limit 0 (inf× over budget)", + "fingerprint": "md:threshold.file.broken_links:{target}/index.md", + "location": { + "lines": { + "begin": 1 + }, + "path": "index.md" + }, + "severity": "major" + } +] diff --git a/crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker-check.sarif b/crates/code-ranker-plugins/src/languages/md/tests/sample/code-ranker-check.sarif similarity index 51% rename from crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker-check.sarif rename to crates/code-ranker-plugins/src/languages/md/tests/sample/code-ranker-check.sarif index 977afe5e..7bb83274 100644 --- a/crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker-check.sarif +++ b/crates/code-ranker-plugins/src/languages/md/tests/sample/code-ranker-check.sarif @@ -21,7 +21,7 @@ "text": "{target}/index.md: broken links 1 exceeds limit 0 (inf× over budget)" }, "partialFingerprints": { - "codeRankerRuleLocation/v1": "threshold.file.broken_links:{target}/index.md" + "codeRankerRuleLocation/v1": "md:threshold.file.broken_links:{target}/index.md" }, "properties": { "graph": "files", @@ -29,33 +29,6 @@ "weight": null }, "ruleId": "threshold.file.broken_links" - }, - { - "level": "error", - "locations": [ - { - "physicalLocation": { - "artifactLocation": { - "uri": "api.md" - }, - "region": { - "startLine": 1 - } - } - } - ], - "message": { - "text": "{target}/api.md: chain cycle: {target}/api.md → {target}/index.md → {target}/guide.md → (back to start)" - }, - "partialFingerprints": { - "codeRankerRuleLocation/v1": "cycle.chain:{target}/api.md" - }, - "properties": { - "graph": "files", - "group": "CYC", - "weight": 3.0 - }, - "ruleId": "cycle.chain" } ], "tool": { @@ -75,22 +48,9 @@ "shortDescription": { "text": "Broken links" } - }, - { - "fullDescription": { - "text": "Three or more units form a strongly-connected component (A → B → C → A); the whole component must be loaded and changed together, defeating modular boundaries." - }, - "helpUri": "https://github.com/ffedoroff/code-ranker/blob/main/docs/code-ranker-cli/ERRORS.md#group-cyc", - "id": "cycle.chain", - "properties": { - "group": "CYC" - }, - "shortDescription": { - "text": "Chain" - } } ], - "version": "3.0.0-alpha.1" + "version": "5.0.0" } } } diff --git a/crates/code-ranker-plugins/src/languages/md/tests/sample/code-ranker-report.json b/crates/code-ranker-plugins/src/languages/md/tests/sample/code-ranker-report.json new file mode 100644 index 00000000..4da0fe42 --- /dev/null +++ b/crates/code-ranker-plugins/src/languages/md/tests/sample/code-ranker-report.json @@ -0,0 +1,250 @@ +{ + "command": "code-ranker report crates/code-ranker-plugins/src/languages/md/tests/sample --config crates/code-ranker-plugins/src/languages/md/tests/sample/code-ranker.toml --output.json.path=crates/code-ranker-plugins/src/languages/md/tests/sample/code-ranker-report.json", + "config_file": "crates/code-ranker-plugins/src/languages/md/tests/sample/code-ranker.toml", + "generated_at": "2026-06-27T21:10:55.050737Z", + "git": { + "branch": "docs/v5-multilang-format", + "commit": "28fd0fd8fc77", + "dirty_files": 8, + "origin": "git@github.com:ffedoroff/code-ranker.git" + }, + "languages": { + "md": { + "graphs": { + "files": { + "attribute_groups": { + "coupling": { + "description": "how tightly modules depend on each other", + "label": "Coupling" + }, + "loc": { + "description": "physical line counts", + "label": "Lines of Code" + } + }, + "cycle_kinds": {}, + "cycles": [], + "edge_attributes": {}, + "edge_kinds": { + "uses": { + "description": "Import dependency — this file imports from the other.", + "flow": true, + "label": "uses" + } + }, + "edges": [ + { + "kind": "uses", + "source": "{target}/api.md", + "target": "{target}/guide.md" + }, + { + "kind": "uses", + "source": "{target}/api.md", + "target": "{target}/index.md" + }, + { + "kind": "uses", + "source": "{target}/guide.md", + "target": "{target}/api.md" + }, + { + "kind": "uses", + "source": "{target}/guide.md", + "target": "{target}/index.md" + }, + { + "kind": "uses", + "source": "{target}/index.md", + "target": "{target}/api.md" + }, + { + "kind": "uses", + "source": "{target}/index.md", + "target": "{target}/guide.md" + } + ], + "node_attributes": { + "broken_links": { + "description": "Links to a local file (not a URL or `#anchor`) that does not exist on disk. A documentation-quality signal — gate it to zero in `check`.", + "direction": "lower_better", + "label": "Broken links", + "remediation": "Fix or remove each dead link — correct the path, restore the target file, or drop the reference.", + "short": "Broken", + "thresholds": { + "info": 0.0, + "warning": 0.0 + }, + "value_type": "int" + }, + "fan_in": { + "description": "Many other units depend on this one, making it risky to change and a single point of failure — though some hubs (shared types) carry high fan-in legitimately.", + "group": "coupling", + "label": "Fan-in", + "name": "Incoming dependencies", + "short": "Fan-in", + "value_type": "int" + }, + "fan_out": { + "description": "This unit depends on many others, so it breaks when any of them change and is hard to test in isolation.", + "group": "coupling", + "label": "Fan-out", + "name": "Outgoing dependencies", + "short": "Fan-out", + "value_type": "int" + }, + "headings": { + "description": "Number of ATX headings (`#` … `######`) — the document's section count.", + "label": "Headings", + "remediation": "Split the document into focused pages, or group sections under fewer top-level headings.", + "short": "H", + "value_type": "int" + }, + "links": { + "description": "Total inline Markdown links `[text](dest)` (local files, URLs and anchors alike).", + "label": "Links", + "remediation": "Group related references, or split the document so each page carries a focused set of links.", + "short": "Links", + "value_type": "int" + }, + "loc": { + "description": "Raw file line count, including blank and comment lines (unlike `sloc`). Large files tend to hold several responsibilities and are harder to review, test, and reuse.", + "group": "loc", + "label": "Lines", + "name": "Total lines", + "remediation": "Split by responsibility into smaller units, extract helpers, and separate data definitions from behavior. For an average breach, break up the largest units first (--top).", + "value_type": "int" + }, + "max_depth": { + "description": "Deepest heading level used (1 = only `#`, 6 = down to `######`). A proxy for how deeply the document is nested.", + "direction": "lower_better", + "label": "Max heading depth", + "remediation": "Flatten the section hierarchy — promote deeply nested headings, or split them into separate documents.", + "short": "H-depth", + "value_type": "int" + } + }, + "node_kinds": { + "file": { + "fill": "#dbe9f4", + "label": "File", + "plural": "Files", + "stroke": "#4d6f9c" + } + }, + "nodes": [ + { + "fan_in": 2, + "fan_out": 2, + "headings": 1, + "id": "{target}/api.md", + "kind": "file", + "links": 2, + "loc": 4, + "max_depth": 1, + "name": "api.md" + }, + { + "fan_in": 2, + "fan_out": 2, + "headings": 1, + "id": "{target}/guide.md", + "kind": "file", + "links": 2, + "loc": 4, + "max_depth": 1, + "name": "guide.md" + }, + { + "broken_links": 1, + "fan_in": 2, + "fan_out": 2, + "headings": 1, + "id": "{target}/index.md", + "kind": "file", + "links": 4, + "loc": 6, + "max_depth": 1, + "name": "index.md" + } + ], + "stats": { + "fan_in": 2, + "fan_out": 2 + }, + "ui": { + "card": [], + "columns": [ + "kind", + "fan_in", + "fan_out", + "headings", + "max_depth", + "links", + "broken_links" + ], + "filter": [], + "size": [], + "sort": [ + "fan_in", + "fan_out", + "headings", + "max_depth", + "links", + "broken_links" + ], + "summary": [ + "fan_in", + "fan_out", + "headings", + "max_depth", + "links", + "broken_links" + ] + } + } + }, + "prompt": { + "cycle_note": "This is **one** dependency cycle; every module in it is listed below so the whole loop is visible. Fix one cycle at a time — `--top 2`+ lists several separate cycles at once and obscures how each one connects.", + "doc_note": "**First, before reading the source**, run `code-ranker docs {id}` — it prints the full principle and, for your language, the usual cause of this exact violation and the smallest correct fix, often with a worked example and how to confirm it. Read it first: it normally names the remedy outright, so you apply it instead of re-deriving the mechanism from the code.", + "focus": "**Focus the research and report primarily on the modules below.**", + "intro": "I want to apply this to some modules in my system.", + "task": [ + "- Prepare a precise, detailed estimate and a report of where the modules below violate it.", + "- If you find more serious violations elsewhere during research, mention them in the report too.", + "- Show a summary of the report in chat.", + "- If any violation is found, suggest saving the report to a file as a plan for a detailed review, named `.code-ranker/-{id}.md`." + ] + } + } + }, + "plugins": [ + "md" + ], + "roots": { + "target": "/Users/roman/work/code-ranker/crates/code-ranker-plugins/src/languages/md/tests/sample" + }, + "schema_version": "5.0", + "target": "/Users/roman/work/code-ranker/crates/code-ranker-plugins/src/languages/md/tests/sample", + "timings": [ + { + "detail": "3 nodes from 3 files", + "ms": 20, + "stage": "md: parse" + }, + { + "detail": "0 nodes annotated", + "ms": 0, + "stage": "md: complexity" + }, + { + "detail": "nodes=3 edges=6", + "ms": 13, + "stage": "md: projection" + } + ], + "versions": { + "code-ranker": "5.0.0" + }, + "workspace": "/Users/roman/work/code-ranker" +} diff --git a/crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker.toml b/crates/code-ranker-plugins/src/languages/md/tests/sample/code-ranker.toml similarity index 63% rename from crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker.toml rename to crates/code-ranker-plugins/src/languages/md/tests/sample/code-ranker.toml index 6ba24753..8110fd7c 100644 --- a/crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker.toml +++ b/crates/code-ranker-plugins/src/languages/md/tests/sample/code-ranker.toml @@ -1,7 +1,7 @@ -version = "4.0" +version = "5.0" # Self-contained config for the code-ranker "markdown" sample fixture. -plugin = "markdown" - +[plugins] +enabled = ["markdown"] # Documentation-quality gate: no broken local links allowed. -[rules.thresholds.file] +[plugins.base.rules.thresholds.file] broken_links = 0 diff --git a/crates/code-ranker-plugins/src/languages/markdown/tests/sample/guide.md b/crates/code-ranker-plugins/src/languages/md/tests/sample/guide.md similarity index 100% rename from crates/code-ranker-plugins/src/languages/markdown/tests/sample/guide.md rename to crates/code-ranker-plugins/src/languages/md/tests/sample/guide.md diff --git a/crates/code-ranker-plugins/src/languages/markdown/tests/sample/index.md b/crates/code-ranker-plugins/src/languages/md/tests/sample/index.md similarity index 100% rename from crates/code-ranker-plugins/src/languages/markdown/tests/sample/index.md rename to crates/code-ranker-plugins/src/languages/md/tests/sample/index.md diff --git a/crates/code-ranker-plugins/src/languages/markdown/tests/structure.rs b/crates/code-ranker-plugins/src/languages/md/tests/structure.rs similarity index 100% rename from crates/code-ranker-plugins/src/languages/markdown/tests/structure.rs rename to crates/code-ranker-plugins/src/languages/md/tests/structure.rs diff --git a/crates/code-ranker-plugins/src/languages/mod.rs b/crates/code-ranker-plugins/src/languages/mod.rs index ed1d7637..70be41b9 100644 --- a/crates/code-ranker-plugins/src/languages/mod.rs +++ b/crates/code-ranker-plugins/src/languages/mod.rs @@ -1,7 +1,7 @@ //! The language plugins. //! -//! Each language lives in its own submodule (`rust`, `python`, `javascript`, -//! `typescript`, `go`); the JavaScript and TypeScript plugins share the +//! Each language lives in its own submodule (`rust`, `python`, `js`, +//! `ts`, `go`); the JavaScript and TypeScript plugins share the //! grammar-agnostic engine in [`ecmascript`]. The plugin structs are //! re-exported at the crate root via `lib.rs`. @@ -11,8 +11,8 @@ pub mod cpp; pub mod csharp; pub mod ecmascript; pub mod go; -pub mod javascript; -pub mod markdown; +pub mod js; +pub mod md; pub mod python; pub mod rust; -pub mod typescript; +pub mod ts; diff --git a/crates/code-ranker-plugins/src/languages/python/config.toml b/crates/code-ranker-plugins/src/languages/python/config.toml index 6a9a4eca..cb5682dd 100644 --- a/crates/code-ranker-plugins/src/languages/python/config.toml +++ b/crates/code-ranker-plugins/src/languages/python/config.toml @@ -19,6 +19,10 @@ doc_lang = "python" doc_overrides = "*" +# Short aliases accepted anywhere a language is named (see `../README.md`). DATA, +# not code; must stay unique across languages. +aliases = ["py"] + # ────────────────────────────────────────────────────────────────────────────── # File-collection extensions, project-detect markers and walk skip-dirs — DATA, # not code (see `../README.md` §3). `extensions` is the source-file extension the @@ -189,11 +193,5 @@ loc_expression_statement = { kind = "expression_statement" } [specs.eta1] description = "Distinct operators (η₁): the count of unique operator token kinds. Python counts the keywords `and or not is in if elif else for while try except finally with return yield await async def del raise pass break continue global exec assert import from as print`, arithmetic / bitwise / comparison / assignment operators (`+ - * / // % ** & | ^ << ~ < <= == != >= > <> := = += -= *= /= //= %= **= >>= <<= &= ^= |= @=`), and `. , @ -> *`." -[specs.n1] -description = "Total operators (N₁): every operator occurrence counted with repetition (the η₁ tokens, not deduplicated)." - [specs.eta2] description = "Distinct operands (η₂): the count of unique operand texts. Python counts identifiers, numeric literals (`integer`, `float`), the constants `True` / `False` / `None`, and string literals — except a bare docstring (a string that is the sole statement of its block), which is treated as a comment, not an operand." - -[specs.n2] -description = "Total operands (N₂): every operand occurrence counted with repetition (the η₂ texts, not deduplicated)." diff --git a/crates/code-ranker-plugins/src/languages/python/mod.rs b/crates/code-ranker-plugins/src/languages/python/mod.rs index 752d18fd..0c2e8be1 100644 --- a/crates/code-ranker-plugins/src/languages/python/mod.rs +++ b/crates/code-ranker-plugins/src/languages/python/mod.rs @@ -37,23 +37,23 @@ impl LanguagePlugin for PythonPlugin { "python" } - fn detect(&self, workspace: &Path, _input: &PluginInput) -> bool { + fn detect(&self, cfg: &toml::Table, workspace: &Path, _input: &PluginInput) -> bool { // Project-detect marker filenames are DATA: read from `config.toml`'s // `detect_markers` (the detect logic stays in Rust). - crate::config::string_list(&CONFIG, "detect_markers") + crate::config::string_list(cfg, "detect_markers") .iter() .any(|f| workspace.join(f).exists()) } - fn levels(&self) -> Vec { + fn levels(&self, cfg: &toml::Table) -> Vec { // The `uses` edge kind is shared vocab: read it from `[edge_kinds]` in // the merged config (Python inherits it verbatim from `defaults.toml`). - let edge_kinds = crate::config::edge_kinds(&CONFIG); + let edge_kinds = crate::config::edge_kinds(cfg); // Structural node-attribute display specs are DATA: Python inherits the // shared `path`/`loc`/`visibility`/`external` from `defaults.toml` (it // adds none of its own), read via the merged config. - let node_attributes = crate::config::node_attributes(&CONFIG); + let node_attributes = crate::config::node_attributes(cfg); vec![ Level { @@ -75,14 +75,14 @@ impl LanguagePlugin for PythonPlugin { node_attributes: BTreeMap::new(), edge_attributes: BTreeMap::new(), attribute_groups: BTreeMap::new(), - node_kinds: function_node_kinds(), + node_kinds: function_node_kinds(cfg), cycle_kinds: default_cycle_kinds(), grouping: None, }, ] } - fn analyze(&self, workspace: &Path, input: &PluginInput) -> Result { + fn analyze(&self, _cfg: &toml::Table, workspace: &Path, input: &PluginInput) -> Result { structure::analyze( workspace, input.ignore_tests, @@ -90,30 +90,34 @@ impl LanguagePlugin for PythonPlugin { ) } - fn metrics(&self, graph: &Graph) -> Vec<(String, MetricInputs)> { + fn metrics(&self, _cfg: &toml::Table, graph: &Graph) -> Vec<(String, MetricInputs)> { file_metrics(graph) } - fn function_units(&self, graph: &Graph) -> Vec<(Node, MetricInputs)> { + fn function_units(&self, _cfg: &toml::Table, graph: &Graph) -> Vec<(Node, MetricInputs)> { function_nodes(graph) } - fn principles(&self, _input: &PluginInput) -> Vec { + fn principles(&self, cfg: &toml::Table, _input: &PluginInput) -> Vec { // The common catalog from `defaults.toml`, with `doc_url` resolved to // `{doc_base}/python/.md` (Python adds no principles of its own). - crate::config::resolved_principles(&CONFIG) + crate::config::resolved_principles(cfg) } - fn report_overrides(&self) -> code_ranker_plugin_api::report::ReportOverride { - code_ranker_plugin_api::list_override::report_override(&CONFIG) + fn report_overrides( + &self, + cfg: &toml::Table, + ) -> code_ranker_plugin_api::report::ReportOverride { + code_ranker_plugin_api::list_override::report_override(cfg) } fn metric_specs( &self, + cfg: &toml::Table, defaults: BTreeMap, ) -> BTreeMap { // Python `[specs.]` overrides (the exact Halstead tokens it counts). - crate::config::apply_spec_overrides(defaults, &CONFIG) + crate::config::apply_spec_overrides(defaults, cfg) } } @@ -141,8 +145,8 @@ fn file_metrics(graph: &Graph) -> Vec<(String, MetricInputs)> { /// rendered via this dictionary — the viewer hardcodes no kind by name). Read /// from `[node_kinds]` in the merged config; Python inherits `function` / /// `method` verbatim from `defaults.toml` and adds none of its own. -fn function_node_kinds() -> BTreeMap { - crate::config::node_kinds(&CONFIG) +fn function_node_kinds(cfg: &toml::Table) -> BTreeMap { + crate::config::node_kinds(cfg) } /// Build function-level units for every `file` node, parsing each file (by its diff --git a/crates/code-ranker-plugins/src/languages/python/tests/dialect.rs b/crates/code-ranker-plugins/src/languages/python/tests/dialect.rs index d4a1d936..073f39a5 100644 --- a/crates/code-ranker-plugins/src/languages/python/tests/dialect.rs +++ b/crates/code-ranker-plugins/src/languages/python/tests/dialect.rs @@ -149,12 +149,12 @@ fn python_trigger_set_documented_in_spec() { // the Python metrics spec, so the trigger list and the spec's "Keyword // look-alike guard set" cannot drift apart. let root = concat!(env!("CARGO_MANIFEST_DIR"), "/../.."); - let path = format!("{root}/languages/python/metrics.md"); + let path = format!("{root}/plugins/python/metrics.md"); let spec = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path}: {e}")); for kw in PY_TRIGGERS { assert!( spec.contains(&format!("`{kw}`")), - "trigger `{kw}` is not documented in languages/python/metrics.md \ + "trigger `{kw}` is not documented in plugins/python/metrics.md \ — spec and FP test drifted" ); } diff --git a/crates/code-ranker-plugins/src/languages/python/tests/mod_rs.rs b/crates/code-ranker-plugins/src/languages/python/tests/mod_rs.rs index 1733fbea..263359c0 100644 --- a/crates/code-ranker-plugins/src/languages/python/tests/mod_rs.rs +++ b/crates/code-ranker-plugins/src/languages/python/tests/mod_rs.rs @@ -16,6 +16,7 @@ fn metrics_and_function_units_skip_unreadable_files() { }], edges: vec![], }; - assert!(PythonPlugin.metrics(&graph).is_empty()); - assert!(PythonPlugin.function_units(&graph).is_empty()); + let cfg = PythonPlugin.config(); + assert!(PythonPlugin.metrics(&cfg, &graph).is_empty()); + assert!(PythonPlugin.function_units(&cfg, &graph).is_empty()); } diff --git a/crates/code-ranker-plugins/src/languages/python/tests/sample/code-ranker-check.codequality.json b/crates/code-ranker-plugins/src/languages/python/tests/sample/code-ranker-check.codequality.json index 3331e572..0b6569f4 100644 --- a/crates/code-ranker-plugins/src/languages/python/tests/sample/code-ranker-check.codequality.json +++ b/crates/code-ranker-plugins/src/languages/python/tests/sample/code-ranker-check.codequality.json @@ -2,7 +2,7 @@ { "check_name": "cycle.chain", "description": "{target}/pkg/chain1.py: chain cycle: {target}/pkg/chain1.py → {target}/pkg/chain3.py → {target}/pkg/chain2.py → (back to start)", - "fingerprint": "cycle.chain:{target}/pkg/chain1.py", + "fingerprint": "python:cycle.chain:{target}/pkg/chain1.py", "location": { "lines": { "begin": 2 @@ -14,7 +14,7 @@ { "check_name": "cycle.mutual", "description": "{target}/pkg/a.py: mutual cycle between {target}/pkg/a.py ↔ {target}/pkg/b.py", - "fingerprint": "cycle.mutual:{target}/pkg/a.py", + "fingerprint": "python:cycle.mutual:{target}/pkg/a.py", "location": { "lines": { "begin": 8 diff --git a/crates/code-ranker-plugins/src/languages/python/tests/sample/code-ranker-check.sarif b/crates/code-ranker-plugins/src/languages/python/tests/sample/code-ranker-check.sarif index 8e529a13..3ef9b0b3 100644 --- a/crates/code-ranker-plugins/src/languages/python/tests/sample/code-ranker-check.sarif +++ b/crates/code-ranker-plugins/src/languages/python/tests/sample/code-ranker-check.sarif @@ -21,7 +21,7 @@ "text": "{target}/pkg/chain1.py: chain cycle: {target}/pkg/chain1.py → {target}/pkg/chain3.py → {target}/pkg/chain2.py → (back to start)" }, "partialFingerprints": { - "codeRankerRuleLocation/v1": "cycle.chain:{target}/pkg/chain1.py" + "codeRankerRuleLocation/v1": "python:cycle.chain:{target}/pkg/chain1.py" }, "properties": { "graph": "files", @@ -48,7 +48,7 @@ "text": "{target}/pkg/a.py: mutual cycle between {target}/pkg/a.py ↔ {target}/pkg/b.py" }, "partialFingerprints": { - "codeRankerRuleLocation/v1": "cycle.mutual:{target}/pkg/a.py" + "codeRankerRuleLocation/v1": "python:cycle.mutual:{target}/pkg/a.py" }, "properties": { "graph": "files", @@ -90,7 +90,7 @@ } } ], - "version": "3.0.0-alpha.1" + "version": "4.0.0" } } } diff --git a/crates/code-ranker-plugins/src/languages/python/tests/sample/code-ranker-report.json b/crates/code-ranker-plugins/src/languages/python/tests/sample/code-ranker-report.json index c098d849..d393d15c 100644 --- a/crates/code-ranker-plugins/src/languages/python/tests/sample/code-ranker-report.json +++ b/crates/code-ranker-plugins/src/languages/python/tests/sample/code-ranker-report.json @@ -1,5 +1,5 @@ { - "command": "code-ranker report crates/code-ranker-plugins/src/languages/python/tests/sample --config crates/code-ranker-plugins/src/languages/python/tests/sample/code-ranker.toml --output.json.path=crates/code-ranker-plugins/src/languages/python/tests/sample/code-ranker-report.json --output.mode quiet", + "command": "code-ranker report crates/code-ranker-plugins/src/languages/python/tests/sample --config crates/code-ranker-plugins/src/languages/python/tests/sample/code-ranker.toml --output.json.path=crates/code-ranker-plugins/src/languages/python/tests/sample/code-ranker-report.json", "config_file": "crates/code-ranker-plugins/src/languages/python/tests/sample/code-ranker.toml", "generated_at": "1970-01-01T00:00:00Z", "git": { @@ -8,1064 +8,1070 @@ "dirty_files": 0, "origin": "git@example.com:org/repo.git" }, - "graphs": { - "files": { - "attribute_groups": { - "complexity": { - "description": "per-function branching, nesting & size", - "label": "Complexity" - }, - "coupling": { - "description": "how tightly modules depend on each other", - "label": "Coupling" - }, - "halstead": { - "description": "operator/operand vocabulary & derived effort", - "label": "Halstead" - }, - "loc": { - "description": "physical line counts", - "label": "Lines of Code" - }, - "maintainability": { - "description": "composite score", - "label": "Maintainability" - } - }, - "cycle_kinds": { - "chain": { - "description": "Three or more units form a strongly-connected component (A → B → C → A); the whole component must be loaded and changed together, defeating modular boundaries.", - "label": "Chain", - "remediation": "Run `code-ranker docs ADP` and follow its instructions." - }, - "mutual": { - "description": "Two units import each other (A ↔ B), so neither can be built, tested, or understood in isolation — the tightest possible coupling.", - "label": "Mutual", - "remediation": "Run `code-ranker docs ADP` and follow its instructions." - } - }, - "cycles": [ - { - "kind": "chain", - "nodes": [ - "{target}/pkg/chain1.py", - "{target}/pkg/chain3.py", - "{target}/pkg/chain2.py" - ] - }, - { - "kind": "mutual", + "languages": { + "python": { + "graphs": { + "files": { + "attribute_groups": { + "complexity": { + "description": "per-function branching, nesting & size", + "label": "Complexity" + }, + "coupling": { + "description": "how tightly modules depend on each other", + "label": "Coupling" + }, + "halstead": { + "description": "operator/operand vocabulary & derived effort", + "label": "Halstead" + }, + "loc": { + "description": "physical line counts", + "label": "Lines of Code" + }, + "maintainability": { + "description": "composite score", + "label": "Maintainability" + } + }, + "cycle_kinds": { + "chain": { + "description": "Three or more units form a strongly-connected component (A → B → C → A); the whole component must be loaded and changed together, defeating modular boundaries.", + "label": "Chain", + "remediation": "Run `code-ranker docs ADP` and follow its instructions." + }, + "mutual": { + "description": "Two units import each other (A ↔ B), so neither can be built, tested, or understood in isolation — the tightest possible coupling.", + "label": "Mutual", + "remediation": "Run `code-ranker docs ADP` and follow its instructions." + } + }, + "cycles": [ + { + "kind": "chain", + "nodes": [ + "{target}/pkg/chain1.py", + "{target}/pkg/chain3.py", + "{target}/pkg/chain2.py" + ] + }, + { + "kind": "mutual", + "nodes": [ + "{target}/pkg/a.py", + "{target}/pkg/b.py" + ] + } + ], + "edge_attributes": {}, + "edge_kinds": { + "uses": { + "description": "Import dependency — this file imports from the other.", + "flow": true, + "label": "uses" + } + }, + "edges": [ + { + "kind": "uses", + "line": 21, + "source": "{target}/pkg/a.py", + "target": "ext:base64" + }, + { + "kind": "uses", + "line": 6, + "source": "{target}/pkg/a.py", + "target": "ext:json" + }, + { + "kind": "uses", + "line": 4, + "source": "{target}/pkg/a.py", + "target": "ext:os" + }, + { + "kind": "uses", + "line": 8, + "source": "{target}/pkg/a.py", + "target": "{target}/pkg/__init__.py" + }, + { + "kind": "uses", + "line": 8, + "source": "{target}/pkg/a.py", + "target": "{target}/pkg/b.py" + }, + { + "kind": "uses", + "line": 9, + "source": "{target}/pkg/a.py", + "target": "{target}/pkg/c.py" + }, + { + "kind": "uses", + "line": 4, + "source": "{target}/pkg/b.py", + "target": "ext:importlib" + }, + { + "kind": "uses", + "line": 6, + "source": "{target}/pkg/b.py", + "target": "{target}/pkg/__init__.py" + }, + { + "kind": "uses", + "line": 6, + "source": "{target}/pkg/b.py", + "target": "{target}/pkg/a.py" + }, + { + "kind": "uses", + "line": 7, + "source": "{target}/pkg/b.py", + "target": "{target}/pkg/c.py" + }, + { + "kind": "uses", + "line": 4, + "source": "{target}/pkg/c.py", + "target": "ext:requests" + }, + { + "kind": "uses", + "line": 2, + "source": "{target}/pkg/chain1.py", + "target": "{target}/pkg/__init__.py" + }, + { + "kind": "uses", + "line": 2, + "source": "{target}/pkg/chain1.py", + "target": "{target}/pkg/chain2.py" + }, + { + "kind": "uses", + "line": 2, + "source": "{target}/pkg/chain2.py", + "target": "{target}/pkg/__init__.py" + }, + { + "kind": "uses", + "line": 2, + "source": "{target}/pkg/chain2.py", + "target": "{target}/pkg/chain3.py" + }, + { + "kind": "uses", + "line": 2, + "source": "{target}/pkg/chain3.py", + "target": "{target}/pkg/__init__.py" + }, + { + "kind": "uses", + "line": 2, + "source": "{target}/pkg/chain3.py", + "target": "{target}/pkg/chain1.py" + }, + { + "kind": "uses", + "line": 4, + "source": "{target}/tests/test_pkg.py", + "target": "{target}/pkg/__init__.py" + }, + { + "kind": "uses", + "line": 4, + "source": "{target}/tests/test_pkg.py", + "target": "{target}/pkg/a.py" + }, + { + "kind": "uses", + "line": 4, + "source": "{target}/tests/test_pkg.py", + "target": "{target}/pkg/b.py" + } + ], + "node_attributes": { + "args": { + "description": "Number of function / closure arguments.", + "direction": "lower_better", + "group": "complexity", + "label": "Args", + "name": "Arguments", + "short": "Args", + "value_type": "int" + }, + "blank": { + "description": "Empty or whitespace-only lines.", + "group": "loc", + "label": "Blank", + "name": "Blank lines", + "short": "Blank", + "value_type": "int" + }, + "branches": { + "description": "Decision points: if / for / while / loop / match arm / try / && / ||. Feeds `cyclomatic`.", + "direction": "lower_better", + "group": "complexity", + "label": "Branches", + "name": "Decision points", + "short": "Branches", + "value_type": "int" + }, + "bugs": { + "calc": "effort ** (2/3) / 3000", + "description": "Estimated delivered bugs — a rough predictor of defect density.", + "direction": "lower_better", + "formula": "effort^⅔ ÷ 3000", + "group": "halstead", + "label": "Bugs", + "name": "Estimated bugs", + "short": "H.bugs", + "value_type": "float" + }, + "cloc": { + "description": "Comment-only lines (inline comments on code lines are not counted).", + "group": "loc", + "label": "Comments", + "name": "Comment lines", + "short": "Comments", + "value_type": "int" + }, + "closures": { + "description": "Number of closures defined in the unit.", + "direction": "lower_better", + "group": "complexity", + "label": "Closures", + "name": "Closures defined", + "short": "Closures", + "value_type": "int" + }, + "cognitive": { + "description": "How hard the code is for a human to follow — not just how many paths it has.
Like `cyclomatic` it adds +1 for each break in linear flow (`if`, `else`, `match`, loops, `catch`, chained `&&` / `||`), but it also adds an extra +1 for every level of nesting: an `if` inside a loop inside an `if` costs far more than three flat `if`s.
That nesting penalty is the point — deeply indented logic is what actually strains a reader, so a high `cognitive` next to a modest `cyclomatic` flags tangled, hard-to-read code.
Summed across every function in the file.", + "direction": "lower_better", + "group": "complexity", + "label": "Cognitive", + "name": "Cognitive complexity", + "short": "Cognitive", + "value_type": "int" + }, + "cycle": { + "description": "Cycle kind this node participates in.", + "group": "coupling", + "label": "Cycle", + "name": "Dependency cycle", + "short": "Cycle", + "value_type": "str" + }, + "cyclomatic": { + "calc": "spaces + branches", + "description": "Number of independent paths through the code — roughly the minimum number of test cases needed to cover every branch.
A function starts at 1 and gains +1 per decision point: each `if` / `else if`, every `match` / `switch` arm, every loop, and each `&&` / `||` in a condition.
Summed across every function in the file, so it grows with both size and branching — the file's total branching burden.
Counts paths only, ignoring how deeply they nest. For a readability-weighted view see `cognitive`.", + "direction": "lower_better", + "formula": "spaces + branches", + "group": "complexity", + "label": "Cyclomatic", + "name": "Cyclomatic complexity", + "omit_at": 1.0, + "short": "Cyclomatic", + "value_type": "int" + }, + "effort": { + "calc": "(eta1 / 2) * (n2 / eta2) * volume", + "description": "Mental effort to implement the algorithm.", + "direction": "lower_better", + "formula": "(eta1 ÷ 2) × (n2 ÷ eta2) × volume", + "group": "halstead", + "label": "Effort", + "name": "Implementation effort", + "short": "H.effort", + "value_type": "float" + }, + "eta1": { + "description": "Distinct operators (η₁): the count of unique operator token kinds. Python counts the keywords `and or not is in if elif else for while try except finally with return yield await async def del raise pass break continue global exec assert import from as print`, arithmetic / bitwise / comparison / assignment operators (`+ - * / // % ** & | ^ << ~ < <= == != >= > <> := = += -= *= /= //= %= **= >>= <<= &= ^= |= @=`), and `. , @ -> *`.", + "direction": "lower_better", + "group": "halstead", + "label": "η₁", + "name": "Unique operators", + "short": "η₁", + "value_type": "int" + }, + "eta2": { + "description": "Distinct operands (η₂): the count of unique operand texts. Python counts identifiers, numeric literals (`integer`, `float`), the constants `True` / `False` / `None`, and string literals — except a bare docstring (a string that is the sole statement of its block), which is treated as a comment, not an operand.", + "direction": "lower_better", + "group": "halstead", + "label": "η₂", + "name": "Unique operands", + "short": "η₂", + "value_type": "int" + }, + "exits": { + "description": "Number of exit points (return/throw) in the unit.", + "direction": "lower_better", + "group": "complexity", + "label": "Exits", + "name": "Exit points", + "short": "Exits", + "value_type": "int" + }, + "external": { + "label": "External", + "value_type": "bool" + }, + "fan_in": { + "description": "Many other units depend on this one, making it risky to change and a single point of failure — though some hubs (shared types) carry high fan-in legitimately.", + "group": "coupling", + "label": "Fan-in", + "name": "Incoming dependencies", + "short": "Fan-in", + "value_type": "int" + }, + "fan_out": { + "description": "This unit depends on many others, so it breaks when any of them change and is hard to test in isolation.", + "group": "coupling", + "label": "Fan-out", + "name": "Outgoing dependencies", + "short": "Fan-out", + "value_type": "int" + }, + "fan_out_external": { + "description": "Number of distinct external libraries this node depends on.", + "group": "coupling", + "label": "Fan-out (external)", + "name": "External dependencies", + "short": "Fan-out (external)", + "value_type": "int" + }, + "hk": { + "abbreviate": true, + "calc": "sloc * (fan_in * fan_out) ** 2", + "description": "Henry-Kafura information-flow complexity: a module that is both a busy crossroads (high fan-in × fan-out) and large — the most expensive place in the codebase to change.", + "direction": "lower_better", + "formula": "sloc × (fan_in × fan_out)²", + "group": "coupling", + "label": "HK", + "name": "God-object risk", + "short": "HK", + "value_type": "float" + }, + "length": { + "calc": "n1 + n2", + "description": "Program length — total operator + operand occurrences.", + "direction": "lower_better", + "formula": "n1 + n2", + "group": "halstead", + "label": "Length", + "name": "Total tokens", + "short": "H.len", + "value_type": "float" + }, + "lloc": { + "description": "Logical lines — counts statements, not physical lines.", + "group": "loc", + "label": "Logical", + "name": "Logical lines", + "short": "Logical", + "value_type": "int" + }, + "loc": { + "description": "Raw file line count, including blank and comment lines (unlike `sloc`). Large files tend to hold several responsibilities and are harder to review, test, and reuse.", + "group": "loc", + "label": "Lines", + "name": "Total lines", + "remediation": "Split by responsibility into smaller units, extract helpers, and separate data definitions from behavior. For an average breach, break up the largest units first (--top).", + "value_type": "int" + }, + "mi": { + "calc": "171 - 5.2*Math.log(volume) - 0.23*cyclomatic - 16.2*Math.log(span_sloc)", + "description": "Maintainability Index (0–100, higher is more maintainable). Derived from Halstead volume, cyclomatic complexity, and SLOC.", + "direction": "higher_better", + "formula": "171 − 5.2·ln(volume) − 0.23·cyclomatic − 16.2·ln(span_sloc)", + "group": "maintainability", + "label": "MI", + "name": "Maintainability index", + "short": "MI", + "value_type": "float" + }, + "mi_sei": { + "calc": "171 - 5.2*Math.log2(volume) - 0.23*cyclomatic - 16.2*Math.log2(span_sloc) + 50*Math.sin(Math.sqrt(cloc / span_sloc * 2.4))", + "description": "SEI variant of the Maintainability Index — adds a bonus for comment density.", + "direction": "higher_better", + "formula": "171 − 5.2·log₂(volume) − 0.23·cyclomatic − 16.2·log₂(span_sloc) + 50·sin(√(cloc ÷ span_sloc × 2.4))", + "group": "maintainability", + "label": "MI (SEI)", + "name": "Maintainability (SEI)", + "short": "MI SEI", + "value_type": "float" + }, + "n1": { + "description": "Total operators (N₁): every operator occurrence counted with repetition (the η₁ tokens, not deduplicated).", + "direction": "lower_better", + "group": "halstead", + "label": "N₁", + "name": "Total operators", + "short": "N₁", + "value_type": "int" + }, + "n2": { + "description": "Total operands (N₂): every operand occurrence counted with repetition (the η₂ texts, not deduplicated).", + "direction": "lower_better", + "group": "halstead", + "label": "N₂", + "name": "Total operands", + "short": "N₂", + "value_type": "int" + }, + "sloc": { + "description": "Source lines of code — lines with at least one non-whitespace, non-comment character. Blank and comment-only lines are not counted (unlike `loc`, the raw file line count).", + "group": "loc", + "label": "Source", + "name": "Source lines", + "short": "SLOC", + "value_type": "int" + }, + "spaces": { + "description": "Unit count: the source file (1) plus each function / impl / trait / closure space. Feeds `cyclomatic`.", + "direction": "lower_better", + "group": "complexity", + "label": "Spaces", + "name": "Code units", + "short": "Spaces", + "value_type": "int" + }, + "span_sloc": { + "description": "Line span of the unit (end_row − start_row) — the size input the Maintainability Index (`mi` / `mi_sei`) is computed from.", + "direction": "lower_better", + "group": "maintainability", + "label": "Span", + "name": "Line span", + "short": "Span", + "value_type": "int" + }, + "time": { + "calc": "effort / 18", + "description": "Estimated implementation time, in seconds.", + "direction": "lower_better", + "formula": "effort ÷ 18", + "group": "halstead", + "label": "Time", + "name": "Coding time (s)", + "short": "H.time(s)", + "value_type": "float" + }, + "visibility": { + "label": "Visibility", + "value_type": "str" + }, + "vocabulary": { + "calc": "eta1 + eta2", + "description": "Vocabulary — distinct operators + operands.", + "direction": "lower_better", + "formula": "eta1 + eta2", + "group": "halstead", + "label": "Vocabulary", + "name": "Distinct symbols", + "short": "H.vocab", + "value_type": "float" + }, + "volume": { + "calc": "length * Math.log2(vocabulary)", + "description": "Algorithm size in bits, from distinct operators and operands.", + "direction": "lower_better", + "formula": "length × log₂(vocabulary)", + "group": "halstead", + "label": "Volume", + "name": "Code volume", + "short": "H.vol", + "value_type": "float" + } + }, + "node_kinds": { + "external": { + "external": true, + "fill": "#f6e2c0", + "label": "Library", + "plural": "Libraries", + "stroke": "#b3801f" + }, + "file": { + "fill": "#dbe9f4", + "label": "File", + "plural": "Files", + "stroke": "#4d6f9c" + } + }, "nodes": [ - "{target}/pkg/a.py", - "{target}/pkg/b.py" - ] - } - ], - "edge_attributes": {}, - "edge_kinds": { - "uses": { - "description": "Import dependency — this file imports from the other.", - "flow": true, - "label": "uses" + { + "external": true, + "id": "ext:base64", + "kind": "external", + "name": "base64" + }, + { + "external": true, + "id": "ext:importlib", + "kind": "external", + "name": "importlib" + }, + { + "external": true, + "id": "ext:json", + "kind": "external", + "name": "json" + }, + { + "external": true, + "id": "ext:os", + "kind": "external", + "name": "os" + }, + { + "external": true, + "id": "ext:requests", + "kind": "external", + "name": "requests" + }, + { + "blank": 1, + "bugs": 0.00174, + "cloc": 8, + "effort": 12, + "eta1": 3, + "eta2": 1, + "fan_in": 6, + "id": "{target}/pkg/__init__.py", + "kind": "file", + "length": 4, + "lloc": 2, + "loc": 10, + "mi": 124.361, + "mi_sei": 153.513, + "n1": 3, + "n2": 1, + "name": "__init__.py", + "sloc": 1, + "spaces": 1, + "span_sloc": 9, + "time": 0.666, + "visibility": "public", + "vocabulary": 4, + "volume": 8 + }, + { + "args": 1, + "blank": 9, + "bugs": 0.0531, + "cloc": 11, + "cycle": "mutual", + "cyclomatic": 4, + "effort": 2013.517, + "eta1": 8, + "eta2": 21, + "exits": 3, + "fan_in": 2, + "fan_out": 3, + "fan_out_external": 3, + "hk": 468, + "id": "{target}/pkg/a.py", + "kind": "file", + "length": 64, + "lloc": 11, + "loc": 28, + "mi": 86.842, + "mi_sei": 91.762, + "n1": 30, + "n2": 34, + "name": "a.py", + "sloc": 13, + "spaces": 4, + "span_sloc": 27, + "time": 111.862, + "visibility": "public", + "vocabulary": 29, + "volume": 310.91 + }, + { + "blank": 6, + "branches": 1, + "bugs": 0.0487, + "cloc": 8, + "cognitive": 1, + "cycle": "mutual", + "cyclomatic": 4, + "effort": 1766.48, + "eta1": 12, + "eta2": 18, + "exits": 2, + "fan_in": 2, + "fan_out": 3, + "fan_out_external": 1, + "hk": 360, + "id": "{target}/pkg/b.py", + "kind": "file", + "length": 45, + "lloc": 9, + "loc": 22, + "mi": 92.692, + "mi_sei": 99.283, + "n1": 21, + "n2": 24, + "name": "b.py", + "sloc": 10, + "spaces": 3, + "span_sloc": 21, + "time": 98.137, + "visibility": "public", + "vocabulary": 30, + "volume": 220.81 + }, + { + "blank": 6, + "bugs": 0.0105, + "cloc": 3, + "cyclomatic": 3, + "effort": 176.927, + "eta1": 5, + "eta2": 8, + "exits": 2, + "fan_in": 2, + "fan_out_external": 1, + "id": "{target}/pkg/c.py", + "kind": "file", + "length": 17, + "lloc": 5, + "loc": 15, + "mi": 106.02, + "mi_sei": 110.421, + "n1": 8, + "n2": 9, + "name": "c.py", + "sloc": 6, + "spaces": 3, + "span_sloc": 14, + "time": 9.829, + "visibility": "public", + "vocabulary": 13, + "volume": 62.907 + }, + { + "blank": 2, + "bugs": 0.00713, + "cloc": 1, + "cycle": "chain", + "cyclomatic": 2, + "effort": 99.06, + "eta1": 5, + "eta2": 4, + "exits": 1, + "fan_in": 1, + "fan_out": 2, + "hk": 12, + "id": "{target}/pkg/chain1.py", + "kind": "file", + "length": 10, + "lloc": 3, + "loc": 7, + "mi": 123.54, + "mi_sei": 132.29, + "n1": 5, + "n2": 5, + "name": "chain1.py", + "sloc": 3, + "spaces": 2, + "span_sloc": 6, + "time": 5.503, + "visibility": "public", + "vocabulary": 9, + "volume": 31.699 + }, + { + "blank": 2, + "bugs": 0.00713, + "cloc": 1, + "cycle": "chain", + "cyclomatic": 2, + "effort": 99.06, + "eta1": 5, + "eta2": 4, + "exits": 1, + "fan_in": 1, + "fan_out": 2, + "hk": 12, + "id": "{target}/pkg/chain2.py", + "kind": "file", + "length": 10, + "lloc": 3, + "loc": 7, + "mi": 123.54, + "mi_sei": 132.29, + "n1": 5, + "n2": 5, + "name": "chain2.py", + "sloc": 3, + "spaces": 2, + "span_sloc": 6, + "time": 5.503, + "visibility": "public", + "vocabulary": 9, + "volume": 31.699 + }, + { + "blank": 2, + "bugs": 0.00713, + "cloc": 1, + "cycle": "chain", + "cyclomatic": 2, + "effort": 99.06, + "eta1": 5, + "eta2": 4, + "exits": 1, + "fan_in": 1, + "fan_out": 2, + "hk": 12, + "id": "{target}/pkg/chain3.py", + "kind": "file", + "length": 10, + "lloc": 3, + "loc": 7, + "mi": 123.54, + "mi_sei": 132.29, + "n1": 5, + "n2": 5, + "name": "chain3.py", + "sloc": 3, + "spaces": 2, + "span_sloc": 6, + "time": 5.503, + "visibility": "public", + "vocabulary": 9, + "volume": 31.699 + }, + { + "args": 4, + "blank": 2, + "branches": 4, + "bugs": 0.076, + "cloc": 7, + "closures": 1, + "cognitive": 5, + "cyclomatic": 6, + "effort": 3447.42, + "eta1": 13, + "eta2": 8, + "exits": 3, + "id": "{target}/pkg/complex.py", + "kind": "file", + "length": 42, + "lloc": 7, + "loc": 16, + "mi": 98.618, + "mi_sei": 110.762, + "n1": 19, + "n2": 23, + "name": "complex.py", + "sloc": 8, + "spaces": 2, + "span_sloc": 15, + "time": 191.523, + "visibility": "public", + "vocabulary": 21, + "volume": 184.477 + }, + { + "blank": 3, + "branches": 1, + "bugs": 0.0141, + "cloc": 3, + "cyclomatic": 3, + "effort": 276.299, + "eta1": 7, + "eta2": 6, + "fan_out": 3, + "id": "{target}/tests/test_pkg.py", + "kind": "file", + "length": 16, + "lloc": 3, + "loc": 9, + "mi": 115.401, + "mi_sei": 131.726, + "n1": 8, + "n2": 8, + "name": "test_pkg.py", + "sloc": 3, + "spaces": 2, + "span_sloc": 8, + "time": 15.349, + "visibility": "public", + "vocabulary": 13, + "volume": 59.207 + } + ], + "stats": { + "blank": 3.666, + "bugs": 0.025, + "cloc": 4.777, + "cognitive": 3, + "cyclomatic": 3.25, + "effort": 887.758, + "fan_in": 2.142, + "fan_out": 2.5, + "hk": 172.8, + "length": 24.222, + "mi": 110.505, + "mi_sei": 121.593, + "sloc": 5.555, + "time": 49.319, + "vocabulary": 15.222, + "volume": 104.6 + }, + "ui": { + "card": [ + "hk", + "sloc" + ], + "columns": [ + "kind", + "cycle", + "sloc", + "hk", + "fan_in", + "fan_out", + "volume", + "bugs", + "effort", + "time", + "length", + "vocabulary", + "cyclomatic", + "cognitive", + "mi", + "mi_sei", + "lloc", + "cloc", + "blank" + ], + "default_sort": "cycle", + "filter": [ + "cycle" + ], + "size": [ + "sloc", + "hk" + ], + "sort": [ + "cycle", + "sloc", + "hk", + "fan_in", + "fan_out", + "volume", + "bugs", + "effort", + "time", + "length", + "vocabulary", + "cyclomatic", + "cognitive", + "mi", + "mi_sei", + "lloc", + "cloc", + "blank" + ], + "summary": [ + "sloc", + "hk", + "fan_in", + "fan_out", + "volume", + "bugs", + "effort", + "time", + "length", + "vocabulary", + "cyclomatic", + "cognitive", + "mi", + "mi_sei", + "lloc", + "cloc", + "blank" + ] + } } }, - "edges": [ - { - "kind": "uses", - "line": 21, - "source": "{target}/pkg/a.py", - "target": "ext:base64" - }, + "principles": [ { - "kind": "uses", - "line": 6, - "source": "{target}/pkg/a.py", - "target": "ext:json" + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/python/CPX.md", + "id": "CPX", + "label": "CPX", + "prompt": "These modules are too complex and I want to reduce their complexity.\nReduce it by splitting large units into smaller single-responsibility ones,\nextracting repeated patterns into shared helpers, flattening deeply nested\ncontrol flow, and breaking large functions into focused helpers.", + "sort_metric": "cognitive", + "title": "CPX — Reduce Complexity" }, { - "kind": "uses", - "line": 4, - "source": "{target}/pkg/a.py", - "target": "ext:os" + "connections": [ + "common" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/python/ADP.md", + "id": "ADP", + "label": "ADP", + "prompt": "The dependency graph between modules must form a DAG. When module A depends\non module B, no chain of dependencies should bring B back to A.\n\nIdentify any cycles in the modules below. For each cycle, propose a concrete\nrefactoring (extract a shared abstraction, invert a dependency, split a module)\nthat makes the graph acyclic without breaking existing functionality.\n\nWhen splitting a module to break a cycle, the new structure should:\n- Preserve existing API contracts\n- Minimise coupling in the new structure\n- Follow the Single Responsibility Principle\n- Not introduce new dependency cycles", + "sort_metric": "cycle", + "title": "ADP — Acyclic Dependencies Principle" }, { - "kind": "uses", - "line": 8, - "source": "{target}/pkg/a.py", - "target": "{target}/pkg/__init__.py" + "connections": [ + "in", + "out" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/python/SRP.md", + "id": "SRP", + "label": "SRP", + "prompt": "A module should have one reason to change — it should serve one actor\nand encapsulate one coherent set of decisions.\n\nFor each module below, identify whether it has more than one responsibility.\nPropose how to split responsibilities so each module changes for only one reason,\nand specify the new module boundaries.", + "sort_metric": "sloc", + "title": "SRP — Single Responsibility Principle" }, { - "kind": "uses", - "line": 8, - "source": "{target}/pkg/a.py", - "target": "{target}/pkg/b.py" + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/python/OCP.md", + "id": "OCP", + "label": "OCP", + "prompt": "A module should be open for extension but closed for modification: new behaviour\nshould be addable without editing existing, working code.\n\nFor each module below, identify extension points that currently require editing\nexisting code (e.g. growing match/switch/if-else chains). Propose an extension\nmechanism (polymorphism, strategy, plug-in registration) so new cases can be added\nwithout modifying these modules.", + "sort_metric": "cyclomatic", + "title": "OCP — Open/Closed Principle" }, { - "kind": "uses", - "line": 9, - "source": "{target}/pkg/a.py", - "target": "{target}/pkg/c.py" + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/python/LSP.md", + "id": "LSP", + "label": "LSP", + "prompt": "Every implementation of an interface must honour its full contract — return-value\ninvariants, error/exception behaviour, side effects, and resource ownership — not\njust the method signatures. A subtype must be substitutable for its base without\nsurprising callers.\n\nIdentify the interface implementations in the modules below. For each, check it can\nreplace any other implementation of the same interface without breaking callers.\nFlag violations and propose fixes.", + "sort_metric": "hk", + "title": "LSP — Liskov Substitution Principle" }, { - "kind": "uses", - "line": 4, - "source": "{target}/pkg/b.py", - "target": "ext:importlib" + "connections": [ + "in" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/python/ISP.md", + "id": "ISP", + "label": "ISP", + "prompt": "Clients should not be forced to depend on methods they do not use. Prefer several\nsmall, focused interfaces over one wide interface.\n\nIdentify interfaces in the modules below that are wider than their consumers need.\nPropose how to split them into narrower interfaces so each consumer depends only on\nwhat it actually uses.", + "sort_metric": "items", + "title": "ISP — Interface Segregation Principle" }, { - "kind": "uses", - "line": 6, - "source": "{target}/pkg/b.py", - "target": "{target}/pkg/__init__.py" + "connections": [ + "common", + "out" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/python/DIP.md", + "id": "DIP", + "label": "DIP", + "prompt": "High-level modules should not depend on low-level modules; both should depend on\nabstractions, and abstractions should not depend on details.\n\nFind places in the modules below where a high-level module depends directly on a\nconcrete low-level type. Propose an abstraction (interface) to invert each such\ndependency, and specify where the concrete implementation should be wired in.", + "sort_metric": "fan_out", + "title": "DIP — Dependency Inversion Principle" }, { - "kind": "uses", - "line": 6, - "source": "{target}/pkg/b.py", - "target": "{target}/pkg/a.py" + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/python/DRY.md", + "id": "DRY", + "label": "DRY", + "prompt": "Every piece of knowledge must have a single authoritative representation.\nDRY is about knowledge duplication, not just code duplication.\n\nIdentify concepts, rules, or policies that are duplicated across the modules\nbelow. For each duplication, propose a canonical location and the refactoring\nneeded to consolidate it.", + "sort_metric": "sloc", + "title": "DRY — Don't Repeat Yourself" }, { - "kind": "uses", - "line": 7, - "source": "{target}/pkg/b.py", - "target": "{target}/pkg/c.py" + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/python/KISS.md", + "id": "KISS", + "label": "KISS", + "prompt": "When two designs solve the same problem, prefer the simpler one — fewer\nabstractions, fewer indirection layers, fewer moving parts.\n\nIdentify over-engineered or needlessly complex constructs in the modules below.\nFor each, describe the simpler alternative and estimate the risk of simplifying.", + "sort_metric": "cognitive", + "title": "KISS — Keep It Simple" }, { - "kind": "uses", - "line": 4, - "source": "{target}/pkg/c.py", - "target": "ext:requests" + "connections": [ + "common", + "out" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/python/LoD.md", + "id": "LoD", + "label": "LoD", + "prompt": "A method should only call methods on: itself, its direct fields,\nits parameters, and objects it constructs locally.\nAvoid `x.foo().bar().baz()` chains that traverse object graphs.\n\nIdentify method chains or deep field traversals in the modules below that\nviolate LoD. For each, propose a narrow accessor or a facade that exposes only\nwhat the caller needs, reducing coupling.", + "sort_metric": "fan_out", + "title": "Law of Demeter — Principle of Least Knowledge" }, { - "kind": "uses", - "line": 2, - "source": "{target}/pkg/chain1.py", - "target": "{target}/pkg/__init__.py" + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/python/MISU.md", + "id": "MISU", + "label": "MISU", + "prompt": "Move correctness from runtime checks into the type system, so invalid states\ncannot be constructed and fail at compile time rather than at runtime.\n\nIdentify data structures or function signatures in the modules below where invalid\nstates are representable at runtime. For each, propose a type-level encoding\n(sum type / enum, newtype, typestate) that makes the invalid state unrepresentable\nby construction.", + "sort_metric": "cyclomatic", + "title": "MISU — Make Invalid States Unrepresentable" }, { - "kind": "uses", - "line": 2, - "source": "{target}/pkg/chain1.py", - "target": "{target}/pkg/chain2.py" + "connections": [ + "common" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/python/CoI.md", + "id": "CoI", + "label": "CoI", + "prompt": "Build behaviour by composing small, focused pieces rather than through deep\ninheritance hierarchies.\n\nIdentify large types that accumulate behaviour in the modules below. Propose how to\ndecompose them into smaller composable parts, and show how consumers would assemble\nthe behaviour they need.", + "sort_metric": "items", + "title": "CoI — Composition Over Inheritance" }, { - "kind": "uses", - "line": 2, - "source": "{target}/pkg/chain2.py", - "target": "{target}/pkg/__init__.py" - }, - { - "kind": "uses", - "line": 2, - "source": "{target}/pkg/chain2.py", - "target": "{target}/pkg/chain3.py" - }, - { - "kind": "uses", - "line": 2, - "source": "{target}/pkg/chain3.py", - "target": "{target}/pkg/__init__.py" - }, - { - "kind": "uses", - "line": 2, - "source": "{target}/pkg/chain3.py", - "target": "{target}/pkg/chain1.py" - }, - { - "kind": "uses", - "line": 4, - "source": "{target}/tests/test_pkg.py", - "target": "{target}/pkg/__init__.py" - }, - { - "kind": "uses", - "line": 4, - "source": "{target}/tests/test_pkg.py", - "target": "{target}/pkg/a.py" - }, - { - "kind": "uses", - "line": 4, - "source": "{target}/tests/test_pkg.py", - "target": "{target}/pkg/b.py" + "connections": [ + "out" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/python/YAGNI.md", + "id": "YAGNI", + "label": "YAGNI", + "prompt": "Build for the problem you have now, not one you imagine you might have later.\nDon't add an abstraction, a generic parameter, or a public API for a hypothetical\nfuture use.\n\nIdentify abstractions, generics, or public APIs in the modules below that were\nadded speculatively. For each, assess whether multiple real callers use it today,\nand propose simplification if not.", + "sort_metric": "sloc", + "title": "YAGNI — You Aren't Gonna Need It" } ], - "node_attributes": { - "args": { - "description": "Number of function / closure arguments.", - "direction": "lower_better", - "group": "complexity", - "label": "Args", - "name": "Arguments", - "short": "Args", - "value_type": "int" - }, - "blank": { - "description": "Empty or whitespace-only lines.", - "group": "loc", - "label": "Blank", - "name": "Blank lines", - "short": "Blank", - "value_type": "int" - }, - "branches": { - "description": "Decision points: if / for / while / loop / match arm / try / && / ||. Feeds `cyclomatic`.", - "direction": "lower_better", - "group": "complexity", - "label": "Branches", - "name": "Decision points", - "short": "Branches", - "value_type": "int" - }, - "bugs": { - "calc": "effort ** (2/3) / 3000", - "description": "Estimated delivered bugs — a rough predictor of defect density.", - "direction": "lower_better", - "formula": "effort^⅔ ÷ 3000", - "group": "halstead", - "label": "Bugs", - "name": "Estimated bugs", - "short": "H.bugs", - "value_type": "float" - }, - "cloc": { - "description": "Comment-only lines (inline comments on code lines are not counted).", - "group": "loc", - "label": "Comments", - "name": "Comment lines", - "short": "Comments", - "value_type": "int" - }, - "closures": { - "description": "Number of closures defined in the unit.", - "direction": "lower_better", - "group": "complexity", - "label": "Closures", - "name": "Closures defined", - "short": "Closures", - "value_type": "int" - }, - "cognitive": { - "description": "How hard the code is for a human to follow — not just how many paths it has.
Like `cyclomatic` it adds +1 for each break in linear flow (`if`, `else`, `match`, loops, `catch`, chained `&&` / `||`), but it also adds an extra +1 for every level of nesting: an `if` inside a loop inside an `if` costs far more than three flat `if`s.
That nesting penalty is the point — deeply indented logic is what actually strains a reader, so a high `cognitive` next to a modest `cyclomatic` flags tangled, hard-to-read code.
Summed across every function in the file.", - "direction": "lower_better", - "group": "complexity", - "label": "Cognitive", - "name": "Cognitive complexity", - "short": "Cognitive", - "value_type": "int" - }, - "cycle": { - "description": "Cycle kind this node participates in.", - "group": "coupling", - "label": "Cycle", - "name": "Dependency cycle", - "short": "Cycle", - "value_type": "str" - }, - "cyclomatic": { - "calc": "spaces + branches", - "description": "Number of independent paths through the code — roughly the minimum number of test cases needed to cover every branch.
A function starts at 1 and gains +1 per decision point: each `if` / `else if`, every `match` / `switch` arm, every loop, and each `&&` / `||` in a condition.
Summed across every function in the file, so it grows with both size and branching — the file's total branching burden.
Counts paths only, ignoring how deeply they nest. For a readability-weighted view see `cognitive`.", - "direction": "lower_better", - "formula": "spaces + branches", - "group": "complexity", - "label": "Cyclomatic", - "name": "Cyclomatic complexity", - "omit_at": 1.0, - "short": "Cyclomatic", - "value_type": "int" - }, - "effort": { - "calc": "(eta1 / 2) * (n2 / eta2) * volume", - "description": "Mental effort to implement the algorithm.", - "direction": "lower_better", - "formula": "(eta1 ÷ 2) × (n2 ÷ eta2) × volume", - "group": "halstead", - "label": "Effort", - "name": "Implementation effort", - "short": "H.effort", - "value_type": "float" - }, - "eta1": { - "description": "Distinct operators (η₁): the count of unique operator token kinds. Python counts the keywords `and or not is in if elif else for while try except finally with return yield await async def del raise pass break continue global exec assert import from as print`, arithmetic / bitwise / comparison / assignment operators (`+ - * / // % ** & | ^ << ~ < <= == != >= > <> := = += -= *= /= //= %= **= >>= <<= &= ^= |= @=`), and `. , @ -> *`.", - "direction": "lower_better", - "group": "halstead", - "label": "η₁", - "name": "Unique operators", - "short": "η₁", - "value_type": "int" - }, - "eta2": { - "description": "Distinct operands (η₂): the count of unique operand texts. Python counts identifiers, numeric literals (`integer`, `float`), the constants `True` / `False` / `None`, and string literals — except a bare docstring (a string that is the sole statement of its block), which is treated as a comment, not an operand.", - "direction": "lower_better", - "group": "halstead", - "label": "η₂", - "name": "Unique operands", - "short": "η₂", - "value_type": "int" - }, - "exits": { - "description": "Number of exit points (return/throw) in the unit.", - "direction": "lower_better", - "group": "complexity", - "label": "Exits", - "name": "Exit points", - "short": "Exits", - "value_type": "int" - }, - "external": { - "label": "External", - "value_type": "bool" - }, - "fan_in": { - "description": "Many other units depend on this one, making it risky to change and a single point of failure — though some hubs (shared types) carry high fan-in legitimately.", - "group": "coupling", - "label": "Fan-in", - "name": "Incoming dependencies", - "short": "Fan-in", - "value_type": "int" - }, - "fan_out": { - "description": "This unit depends on many others, so it breaks when any of them change and is hard to test in isolation.", - "group": "coupling", - "label": "Fan-out", - "name": "Outgoing dependencies", - "short": "Fan-out", - "value_type": "int" - }, - "fan_out_external": { - "description": "Number of distinct external libraries this node depends on.", - "group": "coupling", - "label": "Fan-out (external)", - "name": "External dependencies", - "short": "Fan-out (external)", - "value_type": "int" - }, - "hk": { - "abbreviate": true, - "calc": "sloc * (fan_in * fan_out) ** 2", - "description": "Henry-Kafura information-flow complexity: a module that is both a busy crossroads (high fan-in × fan-out) and large — the most expensive place in the codebase to change.", - "direction": "lower_better", - "formula": "sloc × (fan_in × fan_out)²", - "group": "coupling", - "label": "HK", - "name": "God-object risk", - "short": "HK", - "value_type": "float" - }, - "length": { - "calc": "n1 + n2", - "description": "Program length — total operator + operand occurrences.", - "direction": "lower_better", - "formula": "n1 + n2", - "group": "halstead", - "label": "Length", - "name": "Total tokens", - "short": "H.len", - "value_type": "float" - }, - "lloc": { - "description": "Logical lines — counts statements, not physical lines.", - "group": "loc", - "label": "Logical", - "name": "Logical lines", - "short": "Logical", - "value_type": "int" - }, - "loc": { - "description": "Raw file line count, including blank and comment lines (unlike `sloc`). Large files tend to hold several responsibilities and are harder to review, test, and reuse.", - "group": "loc", - "label": "Lines", - "name": "Total lines", - "remediation": "Split by responsibility into smaller units, extract helpers, and separate data definitions from behavior. For an average breach, break up the largest units first (--top).", - "value_type": "int" - }, - "mi": { - "calc": "171 - 5.2*Math.log(volume) - 0.23*cyclomatic - 16.2*Math.log(span_sloc)", - "description": "Maintainability Index (0–100, higher is more maintainable). Derived from Halstead volume, cyclomatic complexity, and SLOC.", - "direction": "higher_better", - "formula": "171 − 5.2·ln(volume) − 0.23·cyclomatic − 16.2·ln(span_sloc)", - "group": "maintainability", - "label": "MI", - "name": "Maintainability index", - "short": "MI", - "value_type": "float" - }, - "mi_sei": { - "calc": "171 - 5.2*Math.log2(volume) - 0.23*cyclomatic - 16.2*Math.log2(span_sloc) + 50*Math.sin(Math.sqrt(cloc / span_sloc * 2.4))", - "description": "SEI variant of the Maintainability Index — adds a bonus for comment density.", - "direction": "higher_better", - "formula": "171 − 5.2·log₂(volume) − 0.23·cyclomatic − 16.2·log₂(span_sloc) + 50·sin(√(cloc ÷ span_sloc × 2.4))", - "group": "maintainability", - "label": "MI (SEI)", - "name": "Maintainability (SEI)", - "short": "MI SEI", - "value_type": "float" - }, - "n1": { - "description": "Total operators (N₁): every operator occurrence counted with repetition (the η₁ tokens, not deduplicated).", - "direction": "lower_better", - "group": "halstead", - "label": "N₁", - "name": "Total operators", - "short": "N₁", - "value_type": "int" - }, - "n2": { - "description": "Total operands (N₂): every operand occurrence counted with repetition (the η₂ texts, not deduplicated).", - "direction": "lower_better", - "group": "halstead", - "label": "N₂", - "name": "Total operands", - "short": "N₂", - "value_type": "int" - }, - "sloc": { - "description": "Source lines of code — lines with at least one non-whitespace, non-comment character. Blank and comment-only lines are not counted (unlike `loc`, the raw file line count).", - "group": "loc", - "label": "Source", - "name": "Source lines", - "short": "SLOC", - "value_type": "int" - }, - "spaces": { - "description": "Unit count: the source file (1) plus each function / impl / trait / closure space. Feeds `cyclomatic`.", - "direction": "lower_better", - "group": "complexity", - "label": "Spaces", - "name": "Code units", - "short": "Spaces", - "value_type": "int" - }, - "span_sloc": { - "description": "Line span of the unit (end_row − start_row) — the size input the Maintainability Index (`mi` / `mi_sei`) is computed from.", - "direction": "lower_better", - "group": "maintainability", - "label": "Span", - "name": "Line span", - "short": "Span", - "value_type": "int" - }, - "time": { - "calc": "effort / 18", - "description": "Estimated implementation time, in seconds.", - "direction": "lower_better", - "formula": "effort ÷ 18", - "group": "halstead", - "label": "Time", - "name": "Coding time (s)", - "short": "H.time(s)", - "value_type": "float" - }, - "visibility": { - "label": "Visibility", - "value_type": "str" - }, - "vocabulary": { - "calc": "eta1 + eta2", - "description": "Vocabulary — distinct operators + operands.", - "direction": "lower_better", - "formula": "eta1 + eta2", - "group": "halstead", - "label": "Vocabulary", - "name": "Distinct symbols", - "short": "H.vocab", - "value_type": "float" - }, - "volume": { - "calc": "length * Math.log2(vocabulary)", - "description": "Algorithm size in bits, from distinct operators and operands.", - "direction": "lower_better", - "formula": "length × log₂(vocabulary)", - "group": "halstead", - "label": "Volume", - "name": "Code volume", - "short": "H.vol", - "value_type": "float" - } - }, - "node_kinds": { - "external": { - "external": true, - "fill": "#f6e2c0", - "label": "Library", - "plural": "Libraries", - "stroke": "#b3801f" - }, - "file": { - "fill": "#dbe9f4", - "label": "File", - "plural": "Files", - "stroke": "#4d6f9c" - } - }, - "nodes": [ - { - "external": true, - "id": "ext:base64", - "kind": "external", - "name": "base64" - }, - { - "external": true, - "id": "ext:importlib", - "kind": "external", - "name": "importlib" - }, - { - "external": true, - "id": "ext:json", - "kind": "external", - "name": "json" - }, - { - "external": true, - "id": "ext:os", - "kind": "external", - "name": "os" - }, - { - "external": true, - "id": "ext:requests", - "kind": "external", - "name": "requests" - }, - { - "blank": 1, - "bugs": 0.00174, - "cloc": 8, - "effort": 12, - "eta1": 3, - "eta2": 1, - "fan_in": 6, - "id": "{target}/pkg/__init__.py", - "kind": "file", - "length": 4, - "lloc": 2, - "loc": 10, - "mi": 124.361, - "mi_sei": 153.513, - "n1": 3, - "n2": 1, - "name": "__init__.py", - "sloc": 1, - "spaces": 1, - "span_sloc": 9, - "time": 0.666, - "visibility": "public", - "vocabulary": 4, - "volume": 8 - }, - { - "args": 1, - "blank": 9, - "bugs": 0.0531, - "cloc": 11, - "cycle": "mutual", - "cyclomatic": 4, - "effort": 2013.517, - "eta1": 8, - "eta2": 21, - "exits": 3, - "fan_in": 2, - "fan_out": 3, - "fan_out_external": 3, - "hk": 468, - "id": "{target}/pkg/a.py", - "kind": "file", - "length": 64, - "lloc": 11, - "loc": 28, - "mi": 86.842, - "mi_sei": 91.762, - "n1": 30, - "n2": 34, - "name": "a.py", - "sloc": 13, - "spaces": 4, - "span_sloc": 27, - "time": 111.862, - "visibility": "public", - "vocabulary": 29, - "volume": 310.91 - }, - { - "blank": 6, - "branches": 1, - "bugs": 0.0487, - "cloc": 8, - "cognitive": 1, - "cycle": "mutual", - "cyclomatic": 4, - "effort": 1766.48, - "eta1": 12, - "eta2": 18, - "exits": 2, - "fan_in": 2, - "fan_out": 3, - "fan_out_external": 1, - "hk": 360, - "id": "{target}/pkg/b.py", - "kind": "file", - "length": 45, - "lloc": 9, - "loc": 22, - "mi": 92.692, - "mi_sei": 99.283, - "n1": 21, - "n2": 24, - "name": "b.py", - "sloc": 10, - "spaces": 3, - "span_sloc": 21, - "time": 98.137, - "visibility": "public", - "vocabulary": 30, - "volume": 220.81 - }, - { - "blank": 6, - "bugs": 0.0105, - "cloc": 3, - "cyclomatic": 3, - "effort": 176.927, - "eta1": 5, - "eta2": 8, - "exits": 2, - "fan_in": 2, - "fan_out_external": 1, - "id": "{target}/pkg/c.py", - "kind": "file", - "length": 17, - "lloc": 5, - "loc": 15, - "mi": 106.02, - "mi_sei": 110.421, - "n1": 8, - "n2": 9, - "name": "c.py", - "sloc": 6, - "spaces": 3, - "span_sloc": 14, - "time": 9.829, - "visibility": "public", - "vocabulary": 13, - "volume": 62.907 - }, - { - "blank": 2, - "bugs": 0.00713, - "cloc": 1, - "cycle": "chain", - "cyclomatic": 2, - "effort": 99.06, - "eta1": 5, - "eta2": 4, - "exits": 1, - "fan_in": 1, - "fan_out": 2, - "hk": 12, - "id": "{target}/pkg/chain1.py", - "kind": "file", - "length": 10, - "lloc": 3, - "loc": 7, - "mi": 123.54, - "mi_sei": 132.29, - "n1": 5, - "n2": 5, - "name": "chain1.py", - "sloc": 3, - "spaces": 2, - "span_sloc": 6, - "time": 5.503, - "visibility": "public", - "vocabulary": 9, - "volume": 31.699 - }, - { - "blank": 2, - "bugs": 0.00713, - "cloc": 1, - "cycle": "chain", - "cyclomatic": 2, - "effort": 99.06, - "eta1": 5, - "eta2": 4, - "exits": 1, - "fan_in": 1, - "fan_out": 2, - "hk": 12, - "id": "{target}/pkg/chain2.py", - "kind": "file", - "length": 10, - "lloc": 3, - "loc": 7, - "mi": 123.54, - "mi_sei": 132.29, - "n1": 5, - "n2": 5, - "name": "chain2.py", - "sloc": 3, - "spaces": 2, - "span_sloc": 6, - "time": 5.503, - "visibility": "public", - "vocabulary": 9, - "volume": 31.699 - }, - { - "blank": 2, - "bugs": 0.00713, - "cloc": 1, - "cycle": "chain", - "cyclomatic": 2, - "effort": 99.06, - "eta1": 5, - "eta2": 4, - "exits": 1, - "fan_in": 1, - "fan_out": 2, - "hk": 12, - "id": "{target}/pkg/chain3.py", - "kind": "file", - "length": 10, - "lloc": 3, - "loc": 7, - "mi": 123.54, - "mi_sei": 132.29, - "n1": 5, - "n2": 5, - "name": "chain3.py", - "sloc": 3, - "spaces": 2, - "span_sloc": 6, - "time": 5.503, - "visibility": "public", - "vocabulary": 9, - "volume": 31.699 - }, - { - "args": 4, - "blank": 2, - "branches": 4, - "bugs": 0.076, - "cloc": 7, - "closures": 1, - "cognitive": 5, - "cyclomatic": 6, - "effort": 3447.42, - "eta1": 13, - "eta2": 8, - "exits": 3, - "id": "{target}/pkg/complex.py", - "kind": "file", - "length": 42, - "lloc": 7, - "loc": 16, - "mi": 98.618, - "mi_sei": 110.762, - "n1": 19, - "n2": 23, - "name": "complex.py", - "sloc": 8, - "spaces": 2, - "span_sloc": 15, - "time": 191.523, - "visibility": "public", - "vocabulary": 21, - "volume": 184.477 - }, - { - "blank": 3, - "branches": 1, - "bugs": 0.0141, - "cloc": 3, - "cyclomatic": 3, - "effort": 276.299, - "eta1": 7, - "eta2": 6, - "fan_out": 3, - "id": "{target}/tests/test_pkg.py", - "kind": "file", - "length": 16, - "lloc": 3, - "loc": 9, - "mi": 115.401, - "mi_sei": 131.726, - "n1": 8, - "n2": 8, - "name": "test_pkg.py", - "sloc": 3, - "spaces": 2, - "span_sloc": 8, - "time": 15.349, - "visibility": "public", - "vocabulary": 13, - "volume": 59.207 - } - ], - "stats": { - "blank": 3.666, - "bugs": 0.025, - "cloc": 4.777, - "cognitive": 3, - "cyclomatic": 3.25, - "effort": 887.758, - "fan_in": 2.142, - "fan_out": 2.5, - "hk": 172.8, - "length": 24.222, - "mi": 110.505, - "mi_sei": 121.593, - "sloc": 5.555, - "time": 49.319, - "vocabulary": 15.222, - "volume": 104.6 - }, - "ui": { - "card": [ - "hk", - "sloc" - ], - "columns": [ - "kind", - "cycle", - "sloc", - "hk", - "fan_in", - "fan_out", - "volume", - "bugs", - "effort", - "time", - "length", - "vocabulary", - "cyclomatic", - "cognitive", - "mi", - "mi_sei", - "lloc", - "cloc", - "blank" - ], - "default_sort": "cycle", - "filter": [ - "cycle" - ], - "size": [ - "sloc", - "hk" - ], - "sort": [ - "cycle", - "sloc", - "hk", - "fan_in", - "fan_out", - "volume", - "bugs", - "effort", - "time", - "length", - "vocabulary", - "cyclomatic", - "cognitive", - "mi", - "mi_sei", - "lloc", - "cloc", - "blank" - ], - "summary": [ - "sloc", - "hk", - "fan_in", - "fan_out", - "volume", - "bugs", - "effort", - "time", - "length", - "vocabulary", - "cyclomatic", - "cognitive", - "mi", - "mi_sei", - "lloc", - "cloc", - "blank" + "prompt": { + "cycle_note": "This is **one** dependency cycle; every module in it is listed below so the whole loop is visible. Fix one cycle at a time — `--top 2`+ lists several separate cycles at once and obscures how each one connects.", + "doc_note": "**First, before reading the source**, run `code-ranker docs {id}` — it prints the full principle and, for your language, the usual cause of this exact violation and the smallest correct fix, often with a worked example and how to confirm it. Read it first: it normally names the remedy outright, so you apply it instead of re-deriving the mechanism from the code.", + "focus": "**Focus the research and report primarily on the modules below.**", + "intro": "I want to apply this to some modules in my system.", + "task": [ + "- Prepare a precise, detailed estimate and a report of where the modules below violate it.", + "- If you find more serious violations elsewhere during research, mention them in the report too.", + "- Show a summary of the report in chat.", + "- If any violation is found, suggest saving the report to a file as a plan for a detailed review, named `.code-ranker/-{id}.md`." ] } } }, - "plugin": "python", - "principles": [ - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/python/CPX.md", - "id": "CPX", - "label": "CPX", - "prompt": "These modules are too complex and I want to reduce their complexity.\nReduce it by splitting large units into smaller single-responsibility ones,\nextracting repeated patterns into shared helpers, flattening deeply nested\ncontrol flow, and breaking large functions into focused helpers.", - "sort_metric": "cognitive", - "title": "CPX — Reduce Complexity" - }, - { - "connections": [ - "common" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/python/ADP.md", - "id": "ADP", - "label": "ADP", - "prompt": "The dependency graph between modules must form a DAG. When module A depends\non module B, no chain of dependencies should bring B back to A.\n\nIdentify any cycles in the modules below. For each cycle, propose a concrete\nrefactoring (extract a shared abstraction, invert a dependency, split a module)\nthat makes the graph acyclic without breaking existing functionality.\n\nWhen splitting a module to break a cycle, the new structure should:\n- Preserve existing API contracts\n- Minimise coupling in the new structure\n- Follow the Single Responsibility Principle\n- Not introduce new dependency cycles", - "sort_metric": "cycle", - "title": "ADP — Acyclic Dependencies Principle" - }, - { - "connections": [ - "in", - "out" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/python/SRP.md", - "id": "SRP", - "label": "SRP", - "prompt": "A module should have one reason to change — it should serve one actor\nand encapsulate one coherent set of decisions.\n\nFor each module below, identify whether it has more than one responsibility.\nPropose how to split responsibilities so each module changes for only one reason,\nand specify the new module boundaries.", - "sort_metric": "sloc", - "title": "SRP — Single Responsibility Principle" - }, - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/python/OCP.md", - "id": "OCP", - "label": "OCP", - "prompt": "A module should be open for extension but closed for modification: new behaviour\nshould be addable without editing existing, working code.\n\nFor each module below, identify extension points that currently require editing\nexisting code (e.g. growing match/switch/if-else chains). Propose an extension\nmechanism (polymorphism, strategy, plug-in registration) so new cases can be added\nwithout modifying these modules.", - "sort_metric": "cyclomatic", - "title": "OCP — Open/Closed Principle" - }, - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/python/LSP.md", - "id": "LSP", - "label": "LSP", - "prompt": "Every implementation of an interface must honour its full contract — return-value\ninvariants, error/exception behaviour, side effects, and resource ownership — not\njust the method signatures. A subtype must be substitutable for its base without\nsurprising callers.\n\nIdentify the interface implementations in the modules below. For each, check it can\nreplace any other implementation of the same interface without breaking callers.\nFlag violations and propose fixes.", - "sort_metric": "hk", - "title": "LSP — Liskov Substitution Principle" - }, - { - "connections": [ - "in" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/python/ISP.md", - "id": "ISP", - "label": "ISP", - "prompt": "Clients should not be forced to depend on methods they do not use. Prefer several\nsmall, focused interfaces over one wide interface.\n\nIdentify interfaces in the modules below that are wider than their consumers need.\nPropose how to split them into narrower interfaces so each consumer depends only on\nwhat it actually uses.", - "sort_metric": "items", - "title": "ISP — Interface Segregation Principle" - }, - { - "connections": [ - "common", - "out" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/python/DIP.md", - "id": "DIP", - "label": "DIP", - "prompt": "High-level modules should not depend on low-level modules; both should depend on\nabstractions, and abstractions should not depend on details.\n\nFind places in the modules below where a high-level module depends directly on a\nconcrete low-level type. Propose an abstraction (interface) to invert each such\ndependency, and specify where the concrete implementation should be wired in.", - "sort_metric": "fan_out", - "title": "DIP — Dependency Inversion Principle" - }, - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/python/DRY.md", - "id": "DRY", - "label": "DRY", - "prompt": "Every piece of knowledge must have a single authoritative representation.\nDRY is about knowledge duplication, not just code duplication.\n\nIdentify concepts, rules, or policies that are duplicated across the modules\nbelow. For each duplication, propose a canonical location and the refactoring\nneeded to consolidate it.", - "sort_metric": "sloc", - "title": "DRY — Don't Repeat Yourself" - }, - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/python/KISS.md", - "id": "KISS", - "label": "KISS", - "prompt": "When two designs solve the same problem, prefer the simpler one — fewer\nabstractions, fewer indirection layers, fewer moving parts.\n\nIdentify over-engineered or needlessly complex constructs in the modules below.\nFor each, describe the simpler alternative and estimate the risk of simplifying.", - "sort_metric": "cognitive", - "title": "KISS — Keep It Simple" - }, - { - "connections": [ - "common", - "out" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/python/LoD.md", - "id": "LoD", - "label": "LoD", - "prompt": "A method should only call methods on: itself, its direct fields,\nits parameters, and objects it constructs locally.\nAvoid `x.foo().bar().baz()` chains that traverse object graphs.\n\nIdentify method chains or deep field traversals in the modules below that\nviolate LoD. For each, propose a narrow accessor or a facade that exposes only\nwhat the caller needs, reducing coupling.", - "sort_metric": "fan_out", - "title": "Law of Demeter — Principle of Least Knowledge" - }, - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/python/MISU.md", - "id": "MISU", - "label": "MISU", - "prompt": "Move correctness from runtime checks into the type system, so invalid states\ncannot be constructed and fail at compile time rather than at runtime.\n\nIdentify data structures or function signatures in the modules below where invalid\nstates are representable at runtime. For each, propose a type-level encoding\n(sum type / enum, newtype, typestate) that makes the invalid state unrepresentable\nby construction.", - "sort_metric": "cyclomatic", - "title": "MISU — Make Invalid States Unrepresentable" - }, - { - "connections": [ - "common" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/python/CoI.md", - "id": "CoI", - "label": "CoI", - "prompt": "Build behaviour by composing small, focused pieces rather than through deep\ninheritance hierarchies.\n\nIdentify large types that accumulate behaviour in the modules below. Propose how to\ndecompose them into smaller composable parts, and show how consumers would assemble\nthe behaviour they need.", - "sort_metric": "items", - "title": "CoI — Composition Over Inheritance" - }, - { - "connections": [ - "out" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/python/YAGNI.md", - "id": "YAGNI", - "label": "YAGNI", - "prompt": "Build for the problem you have now, not one you imagine you might have later.\nDon't add an abstraction, a generic parameter, or a public API for a hypothetical\nfuture use.\n\nIdentify abstractions, generics, or public APIs in the modules below that were\nadded speculatively. For each, assess whether multiple real callers use it today,\nand propose simplification if not.", - "sort_metric": "sloc", - "title": "YAGNI — You Aren't Gonna Need It" - } + "plugins": [ + "python" ], - "prompt": { - "cycle_note": "This is **one** dependency cycle; every module in it is listed below so the whole loop is visible. Fix one cycle at a time — `--top 2`+ lists several separate cycles at once and obscures how each one connects.", - "doc_note": "**First, before reading the source**, run `code-ranker docs {id}` — it prints the full principle and, for your language, the usual cause of this exact violation and the smallest correct fix, often with a worked example and how to confirm it. Read it first: it normally names the remedy outright, so you apply it instead of re-deriving the mechanism from the code.", - "focus": "**Focus the research and report primarily on the modules below.**", - "intro": "I want to apply this to some modules in my system.", - "task": [ - "- Prepare a precise, detailed estimate and a report of where the modules below violate it.", - "- If you find more serious violations elsewhere during research, mention them in the report too.", - "- Show a summary of the report in chat.", - "- If any violation is found, suggest saving the report to a file as a plan for a detailed review, named `.code-ranker/-{id}.md`." - ] - }, "roots": { "target": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/python/tests/sample" }, - "schema_version": "4.0", + "schema_version": "5.0", "target": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/python/tests/sample", "timings": [ { "detail": "14 nodes from 9 files", "ms": 0, - "stage": "python" + "stage": "python: parse" }, { "detail": "9 nodes annotated", "ms": 0, - "stage": "complexity" + "stage": "python: complexity" }, { "detail": "nodes=14 edges=20", "ms": 0, - "stage": "projection" + "stage": "python: projection" } ], "versions": { - "code-ranker": "4.0.0" + "code-ranker": "5.0.0" }, "workspace": "/home/user/code-ranker" } diff --git a/crates/code-ranker-plugins/src/languages/python/tests/sample/code-ranker.toml b/crates/code-ranker-plugins/src/languages/python/tests/sample/code-ranker.toml index f5161853..7e384993 100644 --- a/crates/code-ranker-plugins/src/languages/python/tests/sample/code-ranker.toml +++ b/crates/code-ranker-plugins/src/languages/python/tests/sample/code-ranker.toml @@ -1,9 +1,9 @@ -version = "4.0" +version = "5.0" # Self-contained config for the code-ranker "python" sample fixture. # Pin the plugin and KEEP test files in the graph (ignore.tests = false) so the # fixture is reproducible regardless of any repo-level config and so that test # files' imports stay visible in the report. -plugin = "python" - -[ignore] +[plugins] +enabled = ["python"] +[plugins.base.ignore] tests = false diff --git a/crates/code-ranker-plugins/src/languages/rust/config.toml b/crates/code-ranker-plugins/src/languages/rust/config.toml index 4ed9eff6..414dbdc8 100644 --- a/crates/code-ranker-plugins/src/languages/rust/config.toml +++ b/crates/code-ranker-plugins/src/languages/rust/config.toml @@ -20,6 +20,10 @@ doc_lang = "rust" doc_overrides = "*" +# Short aliases accepted anywhere a language is named (`--plugins` / `--language` / +# `docs` / `[plugins.]`). DATA, not code; must stay unique across languages. +aliases = ["rs"] + # Project-detect marker filenames — DATA, not code (see `../README.md` §3). Rust # detects a workspace by its `Cargo.toml` manifest; `detect()` reads this list. # (Rust collects sources via `cargo metadata` + `syn`, not by walking a file @@ -281,23 +285,9 @@ description = "Empty or whitespace-only lines. In Rust, measured on production c [specs.eta1] description = "Distinct operators (η₁): the count of unique operator token kinds. Rust counts punctuation & delimiters (`( { [ , . ; -> => ? .. ..=`), arithmetic / bitwise / comparison / assignment operators (`+ - * / % & | ^ << >> == != < > <= >= && || += -= *= /= %= &= |= ^= <<= >>=`), the keywords `async await continue for if let loop match move return unsafe while fn`, `mut` (mutable_specifier) and primitive types, plus the context-sensitive `|| / !`." -[specs.n1] -description = "Total operators (N₁): every operator occurrence counted with repetition (the η₁ tokens, not deduplicated)." - [specs.eta2] description = "Distinct operands (η₂): the count of unique operand texts. Rust counts identifiers, `self`, the `_` wildcard, and literals — string, raw-string, integer, float, boolean and char." -[specs.n2] -description = "Total operands (N₂): every operand occurrence counted with repetition (the η₂ texts, not deduplicated)." - -# ────────────────────────────────────────────────────────────────────────────── -# Rust-specific structural node/edge attributes (display specs). The shared -# `path`/`loc`/`visibility`/`external` come from `defaults.toml`; Rust adds these. -# ────────────────────────────────────────────────────────────────────────────── - -# Rust's own metric category — groups the language-specific attributes below under -# one "Rust-specific" heading (the per-language analogue of `builtin.toml`'s central -# `[categories.*]`), so `docs` lists them together instead of as "(uncategorized)". [attribute_groups.rust] label = "Rust-specific" description = "unsafe, items, traits & other Rust-only facts" diff --git a/crates/code-ranker-plugins/src/languages/rust/mod.rs b/crates/code-ranker-plugins/src/languages/rust/mod.rs index 0925a607..ad112d27 100644 --- a/crates/code-ranker-plugins/src/languages/rust/mod.rs +++ b/crates/code-ranker-plugins/src/languages/rust/mod.rs @@ -46,30 +46,30 @@ impl LanguagePlugin for RustPlugin { "rust" } - fn detect(&self, workspace: &Path, _input: &PluginInput) -> bool { + fn detect(&self, cfg: &toml::Table, workspace: &Path, _input: &PluginInput) -> bool { // Project-detect marker filenames are DATA: read from `config.toml`'s // `detect_markers` (the detect logic stays in Rust). Rust detects on // `Cargo.toml`. (The `cargo metadata` manifest path in `syn_analyze` is // separate — that is cargo machinery, not a detect-marker list.) - crate::config::string_list(&CONFIG, "detect_markers") + crate::config::string_list(cfg, "detect_markers") .iter() .any(|m| workspace.join(m).exists()) } - fn levels(&self) -> Vec { + fn levels(&self, cfg: &toml::Table) -> Vec { // Edge-kind vocabulary (`uses` / `contains` / `reexports` / `super`) is // data: read it from `[edge_kinds]` in `rust/config.toml` (which // overrides the shared `uses` and adds the Rust-only structural kinds). // `collapse.rs` tags edges with the same identifiers via // `config::edge_kind_id`, so the spec and the tagged `kind` can't drift. - let edge_kinds: BTreeMap = crate::config::edge_kinds(&CONFIG); + let edge_kinds: BTreeMap = crate::config::edge_kinds(cfg); // Structural node/edge attribute display specs are DATA: read from the // merged config (`[node_attributes]` / `[edge_attributes]`). The shared // `path`/`loc`/`visibility`/`external` come from `defaults.toml`; Rust's // `crate`/`version`/`items`/`unsafe` (and edge `visibility`) from `rust/config.toml`. - let node_attributes = crate::config::node_attributes(&CONFIG); - let edge_attributes = crate::config::edge_attributes(&CONFIG); + let node_attributes = crate::config::node_attributes(cfg); + let edge_attributes = crate::config::edge_attributes(cfg); vec![ Level { @@ -77,7 +77,7 @@ impl LanguagePlugin for RustPlugin { edge_kinds, node_attributes, edge_attributes, - attribute_groups: crate::config::attribute_groups(&CONFIG), + attribute_groups: crate::config::attribute_groups(cfg), node_kinds: default_node_kinds(), cycle_kinds: default_cycle_kinds(), // Cluster the diagram by the owning crate (compilation unit), not by @@ -86,7 +86,7 @@ impl LanguagePlugin for RustPlugin { // Group by the `crate` node attribute — its key is DATA, // validated against `[node_attributes]`. key: Some( - crate::config::attr_key(&CONFIG, "crate") + crate::config::attr_key(cfg, "crate") .expect("rust/config.toml [node_attributes] is missing `crate`") .into(), ), @@ -100,33 +100,36 @@ impl LanguagePlugin for RustPlugin { node_attributes: BTreeMap::new(), edge_attributes: BTreeMap::new(), attribute_groups: BTreeMap::new(), - node_kinds: function_node_kinds(), + node_kinds: function_node_kinds(cfg), cycle_kinds: default_cycle_kinds(), grouping: None, }, ] } - fn principles(&self, _input: &PluginInput) -> Vec { + fn principles(&self, cfg: &toml::Table, _input: &PluginInput) -> Vec { // The common catalog (from `defaults.toml`) plus the Rust-only metric // lenses (`[[principles]]` in `rust.toml`), with each `doc_url` resolved to // `{doc_base}/rust/.md`. All data-driven via the shared loader. - crate::config::resolved_principles(&CONFIG) + crate::config::resolved_principles(cfg) } - fn report_overrides(&self) -> code_ranker_plugin_api::report::ReportOverride { + fn report_overrides( + &self, + cfg: &toml::Table, + ) -> code_ranker_plugin_api::report::ReportOverride { // Rust's `[report]` patches: e.g. surface the `unsafe` column / stat. - code_ranker_plugin_api::list_override::report_override(&CONFIG) + code_ranker_plugin_api::list_override::report_override(cfg) } - fn analyze(&self, workspace: &Path, input: &PluginInput) -> Result { + fn analyze(&self, _cfg: &toml::Table, workspace: &Path, input: &PluginInput) -> Result { let mut builder = GraphBuilder::new(); syn_analyze(workspace, input.ignore_tests, &mut builder)?; let internal = builder.build(); Ok(collapse_to_files(internal)) } - fn metrics(&self, graph: &Graph) -> Vec<(String, MetricInputs)> { + fn metrics(&self, _cfg: &toml::Table, graph: &Graph) -> Vec<(String, MetricInputs)> { // Each `.rs` file node is re-read (by its absolute-path `id`) and measured // by our `tree-sitter-rust` engine; `#[cfg(test)]` / `#[test]` items are // stripped first so metrics reflect production code only (their lines @@ -146,7 +149,7 @@ impl LanguagePlugin for RustPlugin { out } - fn function_units(&self, graph: &Graph) -> Vec<(Node, MetricInputs)> { + fn function_units(&self, _cfg: &toml::Table, graph: &Graph) -> Vec<(Node, MetricInputs)> { let mut out = Vec::new(); for node in &graph.nodes { if node.kind != code_ranker_plugin_api::node::FILE { @@ -171,24 +174,30 @@ impl LanguagePlugin for RustPlugin { out } - fn versions(&self, _workspace: &Path, _input: &PluginInput) -> Vec<(String, String)> { + fn versions( + &self, + _cfg: &toml::Table, + _workspace: &Path, + _input: &PluginInput, + ) -> Vec<(String, String)> { version_string() .map(|rv| vec![("rustc".to_string(), rv)]) .unwrap_or_default() } - fn roots(&self, _workspace: &Path) -> Vec<(String, String)> { + fn roots(&self, _cfg: &toml::Table, _workspace: &Path) -> Vec<(String, String)> { rust_toolchain_roots() } fn metric_specs( &self, + cfg: &toml::Table, defaults: BTreeMap, ) -> BTreeMap { // Apply the Rust `[specs.]` overrides over the central builtin specs: // the production-only LOC nuance (`#[cfg(test)]` stripped) and the exact // Halstead operator/operand sets Rust counts. - crate::config::apply_spec_overrides(defaults, &CONFIG) + crate::config::apply_spec_overrides(defaults, cfg) } } @@ -198,8 +207,8 @@ impl LanguagePlugin for RustPlugin { /// (Rust labels its free functions `fn`, not the generic `function`). The /// inherited generic `function` entry is also published; it is harmless on this /// off-by-default level (the dialect's `fn_kind` only ever tags `fn` / `method`). -fn function_node_kinds() -> BTreeMap { - crate::config::node_kinds(&CONFIG) +fn function_node_kinds(cfg: &toml::Table) -> BTreeMap { + crate::config::node_kinds(cfg) } #[cfg(test)] diff --git a/crates/code-ranker-plugins/src/languages/rust/tests/mod_rs.rs b/crates/code-ranker-plugins/src/languages/rust/tests/mod_rs.rs index ababec99..fbe9991a 100644 --- a/crates/code-ranker-plugins/src/languages/rust/tests/mod_rs.rs +++ b/crates/code-ranker-plugins/src/languages/rust/tests/mod_rs.rs @@ -31,8 +31,9 @@ fn function_units_extracts_fns_and_methods() { }; // The plugin now returns (node, inputs) pairs (the orchestrator writes the // metrics); this test only checks the node structure. + let cfg = RustPlugin.config(); let units: Vec<_> = RustPlugin - .function_units(&graph) + .function_units(&cfg, &graph) .into_iter() .map(|(n, _)| n) .collect(); @@ -469,12 +470,12 @@ fn rust_trigger_set_documented_in_spec() { // in Rust's metrics spec, so the trigger list and the spec's "Keyword // look-alike guard set" cannot drift apart. let root = concat!(env!("CARGO_MANIFEST_DIR"), "/../.."); - let path = format!("{root}/languages/rust/metrics.md"); + let path = format!("{root}/plugins/rust/metrics.md"); let spec = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path}: {e}")); for kw in RUST_TRIGGERS { assert!( spec.contains(&format!("`{kw}`")), - "trigger `{kw}` is not documented in languages/rust/metrics.md — spec and FP test drifted" + "trigger `{kw}` is not documented in plugins/rust/metrics.md — spec and FP test drifted" ); } } @@ -743,7 +744,8 @@ fn metric_specs_override_adds_rust_cfg_test_note() { "the shared default must stay language-neutral" ); - let refined = RustPlugin.metric_specs(defaults); + let cfg = RustPlugin.config(); + let refined = RustPlugin.metric_specs(&cfg, defaults); for key in ["sloc", "lloc", "cloc", "blank"] { let desc = refined[key].description.as_deref().unwrap_or(""); assert!( @@ -767,8 +769,9 @@ fn metrics_and_function_units_skip_unreadable_files() { }], edges: vec![], }; - assert!(RustPlugin.metrics(&graph).is_empty()); - assert!(RustPlugin.function_units(&graph).is_empty()); + let cfg = RustPlugin.config(); + assert!(RustPlugin.metrics(&cfg, &graph).is_empty()); + assert!(RustPlugin.function_units(&cfg, &graph).is_empty()); } #[test] diff --git a/crates/code-ranker-plugins/src/languages/rust/tests/sample/code-ranker-check.codequality.json b/crates/code-ranker-plugins/src/languages/rust/tests/sample/code-ranker-check.codequality.json index 3155ccf0..c3324c0f 100644 --- a/crates/code-ranker-plugins/src/languages/rust/tests/sample/code-ranker-check.codequality.json +++ b/crates/code-ranker-plugins/src/languages/rust/tests/sample/code-ranker-check.codequality.json @@ -2,7 +2,7 @@ { "check_name": "cycle.chain", "description": "{target}/src/chain/one.rs: chain cycle: {target}/src/chain/one.rs → {target}/src/chain/three.rs → {target}/src/chain/two.rs → (back to start)", - "fingerprint": "cycle.chain:{target}/src/chain/one.rs", + "fingerprint": "rust:cycle.chain:{target}/src/chain/one.rs", "location": { "lines": { "begin": 2 @@ -14,7 +14,7 @@ { "check_name": "cycle.mutual", "description": "{target}/src/a.rs: mutual cycle between {target}/src/a.rs ↔ {target}/src/b.rs", - "fingerprint": "cycle.mutual:{target}/src/a.rs", + "fingerprint": "rust:cycle.mutual:{target}/src/a.rs", "location": { "lines": { "begin": 4 diff --git a/crates/code-ranker-plugins/src/languages/rust/tests/sample/code-ranker-check.sarif b/crates/code-ranker-plugins/src/languages/rust/tests/sample/code-ranker-check.sarif index 08255bd9..862ed52b 100644 --- a/crates/code-ranker-plugins/src/languages/rust/tests/sample/code-ranker-check.sarif +++ b/crates/code-ranker-plugins/src/languages/rust/tests/sample/code-ranker-check.sarif @@ -21,7 +21,7 @@ "text": "{target}/src/chain/one.rs: chain cycle: {target}/src/chain/one.rs → {target}/src/chain/three.rs → {target}/src/chain/two.rs → (back to start)" }, "partialFingerprints": { - "codeRankerRuleLocation/v1": "cycle.chain:{target}/src/chain/one.rs" + "codeRankerRuleLocation/v1": "rust:cycle.chain:{target}/src/chain/one.rs" }, "properties": { "graph": "files", @@ -48,7 +48,7 @@ "text": "{target}/src/a.rs: mutual cycle between {target}/src/a.rs ↔ {target}/src/b.rs" }, "partialFingerprints": { - "codeRankerRuleLocation/v1": "cycle.mutual:{target}/src/a.rs" + "codeRankerRuleLocation/v1": "rust:cycle.mutual:{target}/src/a.rs" }, "properties": { "graph": "files", @@ -90,7 +90,7 @@ } } ], - "version": "3.0.0-alpha.1" + "version": "4.0.0" } } } diff --git a/crates/code-ranker-plugins/src/languages/rust/tests/sample/code-ranker-report.json b/crates/code-ranker-plugins/src/languages/rust/tests/sample/code-ranker-report.json index 6946346c..0bdb5b45 100644 --- a/crates/code-ranker-plugins/src/languages/rust/tests/sample/code-ranker-report.json +++ b/crates/code-ranker-plugins/src/languages/rust/tests/sample/code-ranker-report.json @@ -1,5 +1,5 @@ { - "command": "code-ranker report crates/code-ranker-plugins/src/languages/rust/tests/sample --config crates/code-ranker-plugins/src/languages/rust/tests/sample/code-ranker.toml --output.json.path=crates/code-ranker-plugins/src/languages/rust/tests/sample/code-ranker-report.json --output.mode quiet", + "command": "code-ranker report crates/code-ranker-plugins/src/languages/rust/tests/sample --config crates/code-ranker-plugins/src/languages/rust/tests/sample/code-ranker.toml --output.json.path=crates/code-ranker-plugins/src/languages/rust/tests/sample/code-ranker-report.json", "config_file": "crates/code-ranker-plugins/src/languages/rust/tests/sample/code-ranker.toml", "generated_at": "1970-01-01T00:00:00Z", "git": { @@ -8,1765 +8,1771 @@ "dirty_files": 0, "origin": "git@example.com:org/repo.git" }, - "graphs": { - "files": { - "attribute_groups": { - "complexity": { - "description": "per-function branching, nesting & size", - "label": "Complexity" - }, - "coupling": { - "description": "how tightly modules depend on each other", - "label": "Coupling" - }, - "halstead": { - "description": "operator/operand vocabulary & derived effort", - "label": "Halstead" - }, - "loc": { - "description": "physical line counts", - "label": "Lines of Code" - }, - "maintainability": { - "description": "composite score", - "label": "Maintainability" - }, - "rust": { - "description": "unsafe, items, traits & other Rust-only facts", - "label": "Rust-specific" - } - }, - "cycle_kinds": { - "chain": { - "description": "Three or more units form a strongly-connected component (A → B → C → A); the whole component must be loaded and changed together, defeating modular boundaries.", - "label": "Chain", - "remediation": "Run `code-ranker docs ADP` and follow its instructions." - }, - "mutual": { - "description": "Two units import each other (A ↔ B), so neither can be built, tested, or understood in isolation — the tightest possible coupling.", - "label": "Mutual", - "remediation": "Run `code-ranker docs ADP` and follow its instructions." - } - }, - "cycles": [ - { - "kind": "chain", - "nodes": [ - "{target}/src/chain/one.rs", - "{target}/src/chain/three.rs", - "{target}/src/chain/two.rs" - ] - }, - { - "kind": "mutual", + "languages": { + "rust": { + "graphs": { + "files": { + "attribute_groups": { + "complexity": { + "description": "per-function branching, nesting & size", + "label": "Complexity" + }, + "coupling": { + "description": "how tightly modules depend on each other", + "label": "Coupling" + }, + "halstead": { + "description": "operator/operand vocabulary & derived effort", + "label": "Halstead" + }, + "loc": { + "description": "physical line counts", + "label": "Lines of Code" + }, + "maintainability": { + "description": "composite score", + "label": "Maintainability" + }, + "rust": { + "description": "unsafe, items, traits & other Rust-only facts", + "label": "Rust-specific" + } + }, + "cycle_kinds": { + "chain": { + "description": "Three or more units form a strongly-connected component (A → B → C → A); the whole component must be loaded and changed together, defeating modular boundaries.", + "label": "Chain", + "remediation": "Run `code-ranker docs ADP` and follow its instructions." + }, + "mutual": { + "description": "Two units import each other (A ↔ B), so neither can be built, tested, or understood in isolation — the tightest possible coupling.", + "label": "Mutual", + "remediation": "Run `code-ranker docs ADP` and follow its instructions." + } + }, + "cycles": [ + { + "kind": "chain", + "nodes": [ + "{target}/src/chain/one.rs", + "{target}/src/chain/three.rs", + "{target}/src/chain/two.rs" + ] + }, + { + "kind": "mutual", + "nodes": [ + "{target}/src/a.rs", + "{target}/src/b.rs" + ] + } + ], + "edge_attributes": { + "visibility": { + "label": "Visibility", + "value_type": "str" + } + }, + "edge_kinds": { + "contains": { + "description": "Module ownership — the parent declares the child module (`mod foo;` / `pub mod foo;`), so `foo.rs` (or `foo/mod.rs`) belongs to it.
This is the Rust module tree: structure, not a code dependency.
Kept in the data but not drawn on the main map, and excluded from fan-in / fan-out / HK / cycles.", + "flow": false, + "label": "contains" + }, + "reexports": { + "description": "Re-export (`pub use foo::Item;`) — re-publishes another file's item as part of this file's public API (the crate-root / prelude facade, e.g. `lib.rs` doing `pub use access_scope::AccessScope;`).
A facade, not a dependency: excluded from fan-in / fan-out / HK / cycles and not drawn on the main map, like `contains`.
A consumer's `use this_crate::Item` is attributed to the file that defines `Item`, so re-export hubs (`lib.rs` / `mod.rs`) collect no false coupling — the `pub use` is still recorded here so you can see what a file exposes.", + "flow": false, + "label": "reexport" + }, + "super": { + "description": "Namespace pull from an enclosing module — a glob `use` that reaches *up* the module tree (`use super::*`, `use crate::::*`), bringing the parent's items into the child's scope.
Usually structural scope-sugar (a module split across files referring back to itself). But if the child actually uses a parent item brought in by the glob, it IS a real back-dependency — technically a cycle. code-ranker can't tell the two apart without name resolution, so it treats `super` as a **low-priority** cycle and leaves it non-flow: deprioritized next to obvious cross-module cycles.
Kept in the data but not drawn on the main map, and excluded from fan-in / fan-out / HK / cycles — like `contains`.", + "flow": false, + "label": "super" + }, + "uses": { + "description": "Code dependency — this file references an item the target file defines.
Captured from `use path::Item;`, a qualified path (`crate::a::Item`, `other_crate::Item`), or a derive (`#[derive(serde::Serialize)]`).
The path resolves to the file that defines the item (following `pub use` re-exports), so the edge points at the definition, not a re-export hub.
This is the real dependency: it counts toward fan-in / fan-out, Henry-Kafura coupling and cycles.", + "flow": true, + "label": "uses" + } + }, + "edges": [ + { + "kind": "contains", + "source": "{target}/helper/src/lib.rs", + "target": "{target}/helper/src/gadget.rs" + }, + { + "kind": "contains", + "source": "{target}/helper/src/lib.rs", + "target": "{target}/helper/src/widget.rs" + }, + { + "kind": "uses", + "line": 15, + "source": "{target}/src/a.rs", + "target": "ext:serde" + }, + { + "kind": "uses", + "line": 4, + "source": "{target}/src/a.rs", + "target": "{target}/src/b.rs" + }, + { + "kind": "uses", + "line": 6, + "source": "{target}/src/a.rs", + "target": "{target}/src/c.rs" + }, + { + "kind": "uses", + "source": "{target}/src/b.rs", + "target": "ext:once_cell" + }, + { + "kind": "uses", + "line": 8, + "source": "{target}/src/b.rs", + "target": "{target}/src/a.rs" + }, + { + "kind": "contains", + "source": "{target}/src/chain.rs", + "target": "{target}/src/chain/one.rs" + }, + { + "kind": "contains", + "source": "{target}/src/chain.rs", + "target": "{target}/src/chain/three.rs" + }, + { + "kind": "contains", + "source": "{target}/src/chain.rs", + "target": "{target}/src/chain/two.rs" + }, + { + "kind": "uses", + "line": 2, + "source": "{target}/src/chain/one.rs", + "target": "{target}/src/chain/two.rs" + }, + { + "kind": "uses", + "line": 2, + "source": "{target}/src/chain/three.rs", + "target": "{target}/src/chain/one.rs" + }, + { + "kind": "uses", + "line": 2, + "source": "{target}/src/chain/two.rs", + "target": "{target}/src/chain/three.rs" + }, + { + "kind": "uses", + "line": 13, + "source": "{target}/src/cross.rs", + "target": "{target}/helper/src/gadget.rs" + }, + { + "kind": "uses", + "line": 15, + "source": "{target}/src/cross.rs", + "target": "{target}/helper/src/lib.rs" + }, + { + "kind": "uses", + "line": 11, + "source": "{target}/src/cross.rs", + "target": "{target}/helper/src/widget.rs" + }, + { + "kind": "contains", + "source": "{target}/src/cycle_examples.rs", + "target": "{target}/src/cycle_examples/reex_hub.rs" + }, + { + "kind": "contains", + "source": "{target}/src/cycle_examples.rs", + "target": "{target}/src/cycle_examples/reex_spoke.rs" + }, + { + "kind": "contains", + "source": "{target}/src/cycle_examples.rs", + "target": "{target}/src/cycle_examples/sup_loose.rs" + }, + { + "kind": "contains", + "source": "{target}/src/cycle_examples.rs", + "target": "{target}/src/cycle_examples/sup_parent.rs" + }, + { + "kind": "reexports", + "line": 8, + "source": "{target}/src/cycle_examples/reex_hub.rs", + "target": "{target}/src/cycle_examples/reex_spoke.rs", + "visibility": "public" + }, + { + "kind": "uses", + "line": 6, + "source": "{target}/src/cycle_examples/reex_spoke.rs", + "target": "{target}/src/cycle_examples/reex_hub.rs" + }, + { + "kind": "contains", + "source": "{target}/src/cycle_examples/sup_loose.rs", + "target": "{target}/src/cycle_examples/sup_loose/child.rs" + }, + { + "kind": "uses", + "line": 8, + "source": "{target}/src/cycle_examples/sup_loose.rs", + "target": "{target}/src/cycle_examples/sup_loose/child.rs" + }, + { + "kind": "super", + "line": 9, + "source": "{target}/src/cycle_examples/sup_loose/child.rs", + "target": "{target}/src/cycle_examples/sup_loose.rs" + }, + { + "kind": "contains", + "source": "{target}/src/cycle_examples/sup_parent.rs", + "target": "{target}/src/cycle_examples/sup_parent/child.rs" + }, + { + "kind": "uses", + "line": 16, + "source": "{target}/src/cycle_examples/sup_parent.rs", + "target": "{target}/src/cycle_examples/sup_parent/child.rs" + }, + { + "kind": "super", + "line": 11, + "source": "{target}/src/cycle_examples/sup_parent/child.rs", + "target": "{target}/src/cycle_examples/sup_parent.rs" + }, + { + "kind": "uses", + "source": "{target}/src/derives.rs", + "target": "ext:serde" + }, + { + "kind": "uses", + "line": 10, + "source": "{target}/src/foo.rs", + "target": "{target}/src/b.rs" + }, + { + "kind": "contains", + "source": "{target}/src/foo.rs", + "target": "{target}/src/foo/bar.rs" + }, + { + "kind": "super", + "line": 13, + "source": "{target}/src/foo/bar.rs", + "target": "{target}/src/foo.rs" + }, + { + "kind": "contains", + "source": "{target}/src/lib.rs", + "target": "{target}/src/a.rs" + }, + { + "kind": "reexports", + "line": 42, + "source": "{target}/src/lib.rs", + "target": "{target}/src/a.rs", + "visibility": "public" + }, + { + "kind": "uses", + "line": 64, + "source": "{target}/src/lib.rs", + "target": "{target}/src/a.rs" + }, + { + "kind": "contains", + "source": "{target}/src/lib.rs", + "target": "{target}/src/b.rs" + }, + { + "kind": "uses", + "line": 65, + "source": "{target}/src/lib.rs", + "target": "{target}/src/b.rs" + }, + { + "kind": "contains", + "source": "{target}/src/lib.rs", + "target": "{target}/src/c.rs" + }, + { + "kind": "contains", + "source": "{target}/src/lib.rs", + "target": "{target}/src/chain.rs" + }, + { + "kind": "contains", + "source": "{target}/src/lib.rs", + "target": "{target}/src/complex.rs" + }, + { + "kind": "contains", + "source": "{target}/src/lib.rs", + "target": "{target}/src/cross.rs" + }, + { + "kind": "contains", + "source": "{target}/src/lib.rs", + "target": "{target}/src/cycle_examples.rs" + }, + { + "kind": "contains", + "source": "{target}/src/lib.rs", + "target": "{target}/src/derives.rs" + }, + { + "kind": "contains", + "source": "{target}/src/lib.rs", + "target": "{target}/src/foo.rs" + }, + { + "kind": "uses", + "source": "{target}/src/lib.rs", + "target": "{target}/src/foo.rs" + }, + { + "kind": "contains", + "source": "{target}/src/lib.rs", + "target": "{target}/src/macros.rs" + }, + { + "kind": "contains", + "source": "{target}/src/lib.rs", + "target": "{target}/src/relocated/custom.rs" + }, + { + "kind": "uses", + "line": 7, + "source": "{target}/src/relocated/custom.rs", + "target": "{target}/src/c.rs" + } + ], + "node_attributes": { + "args": { + "description": "Number of function / closure arguments.", + "direction": "lower_better", + "group": "complexity", + "label": "Args", + "name": "Arguments", + "short": "Args", + "value_type": "int" + }, + "attrs": { + "description": "Names of attributes (other than `derive`) applied in the file (e.g. `tokio,serde`), production code only.", + "group": "rust", + "label": "Attributes", + "value_type": "str" + }, + "blank": { + "description": "Empty or whitespace-only lines. In Rust, measured on production code only (inline `#[cfg(test)]` / `#[test]` tests are excluded, like `sloc`; their lines are `tloc`).", + "group": "loc", + "label": "Blank", + "name": "Blank lines", + "short": "Blank", + "value_type": "int" + }, + "branches": { + "description": "Decision points: if / for / while / loop / match arm / try / && / ||. Feeds `cyclomatic`.", + "direction": "lower_better", + "group": "complexity", + "label": "Branches", + "name": "Decision points", + "short": "Branches", + "value_type": "int" + }, + "bugs": { + "calc": "effort ** (2/3) / 3000", + "description": "Estimated delivered bugs — a rough predictor of defect density.", + "direction": "lower_better", + "formula": "effort^⅔ ÷ 3000", + "group": "halstead", + "label": "Bugs", + "name": "Estimated bugs", + "short": "H.bugs", + "value_type": "float" + }, + "cloc": { + "description": "Comment-only lines (inline comments on code lines are not counted). In Rust, measured on production code only (inline `#[cfg(test)]` / `#[test]` tests are excluded, like `sloc`; their lines are `tloc`).", + "group": "loc", + "label": "Comments", + "name": "Comment lines", + "short": "Comments", + "value_type": "int" + }, + "closures": { + "description": "Number of closures defined in the unit.", + "direction": "lower_better", + "group": "complexity", + "label": "Closures", + "name": "Closures defined", + "short": "Closures", + "value_type": "int" + }, + "cognitive": { + "description": "How hard the code is for a human to follow — not just how many paths it has.
Like `cyclomatic` it adds +1 for each break in linear flow (`if`, `else`, `match`, loops, `catch`, chained `&&` / `||`), but it also adds an extra +1 for every level of nesting: an `if` inside a loop inside an `if` costs far more than three flat `if`s.
That nesting penalty is the point — deeply indented logic is what actually strains a reader, so a high `cognitive` next to a modest `cyclomatic` flags tangled, hard-to-read code.
Summed across every function in the file.", + "direction": "lower_better", + "group": "complexity", + "label": "Cognitive", + "name": "Cognitive complexity", + "short": "Cognitive", + "value_type": "int" + }, + "crate": { + "label": "Crate", + "value_type": "str" + }, + "cycle": { + "description": "Cycle kind this node participates in.", + "group": "coupling", + "label": "Cycle", + "name": "Dependency cycle", + "short": "Cycle", + "value_type": "str" + }, + "cyclomatic": { + "calc": "spaces + branches", + "description": "Number of independent paths through the code — roughly the minimum number of test cases needed to cover every branch.
A function starts at 1 and gains +1 per decision point: each `if` / `else if`, every `match` / `switch` arm, every loop, and each `&&` / `||` in a condition.
Summed across every function in the file, so it grows with both size and branching — the file's total branching burden.
Counts paths only, ignoring how deeply they nest. For a readability-weighted view see `cognitive`.", + "direction": "lower_better", + "formula": "spaces + branches", + "group": "complexity", + "label": "Cyclomatic", + "name": "Cyclomatic complexity", + "omit_at": 1.0, + "short": "Cyclomatic", + "value_type": "int" + }, + "derives": { + "description": "Names of the `#[derive(...)]` traits used in the file (e.g. `Serialize,Debug`), production code only.", + "group": "rust", + "label": "Derives", + "value_type": "str" + }, + "effort": { + "calc": "(eta1 / 2) * (n2 / eta2) * volume", + "description": "Mental effort to implement the algorithm.", + "direction": "lower_better", + "formula": "(eta1 ÷ 2) × (n2 ÷ eta2) × volume", + "group": "halstead", + "label": "Effort", + "name": "Implementation effort", + "short": "H.effort", + "value_type": "float" + }, + "eta1": { + "description": "Distinct operators (η₁): the count of unique operator token kinds. Rust counts punctuation & delimiters (`( { [ , . ; -> => ? .. ..=`), arithmetic / bitwise / comparison / assignment operators (`+ - * / % & | ^ << >> == != < > <= >= && || += -= *= /= %= &= |= ^= <<= >>=`), the keywords `async await continue for if let loop match move return unsafe while fn`, `mut` (mutable_specifier) and primitive types, plus the context-sensitive `|| / !`.", + "direction": "lower_better", + "group": "halstead", + "label": "η₁", + "name": "Unique operators", + "short": "η₁", + "value_type": "int" + }, + "eta2": { + "description": "Distinct operands (η₂): the count of unique operand texts. Rust counts identifiers, `self`, the `_` wildcard, and literals — string, raw-string, integer, float, boolean and char.", + "direction": "lower_better", + "group": "halstead", + "label": "η₂", + "name": "Unique operands", + "short": "η₂", + "value_type": "int" + }, + "exits": { + "description": "Number of exit points (return/throw) in the unit.", + "direction": "lower_better", + "group": "complexity", + "label": "Exits", + "name": "Exit points", + "short": "Exits", + "value_type": "int" + }, + "external": { + "label": "External", + "value_type": "bool" + }, + "fan_in": { + "description": "Many other units depend on this one, making it risky to change and a single point of failure — though some hubs (shared types) carry high fan-in legitimately.", + "group": "coupling", + "label": "Fan-in", + "name": "Incoming dependencies", + "short": "Fan-in", + "value_type": "int" + }, + "fan_out": { + "description": "This unit depends on many others, so it breaks when any of them change and is hard to test in isolation.", + "group": "coupling", + "label": "Fan-out", + "name": "Outgoing dependencies", + "short": "Fan-out", + "value_type": "int" + }, + "fan_out_external": { + "description": "Number of distinct external libraries this node depends on.", + "group": "coupling", + "label": "Fan-out (external)", + "name": "External dependencies", + "short": "Fan-out (external)", + "value_type": "int" + }, + "hk": { + "abbreviate": true, + "calc": "sloc * (fan_in * fan_out) ** 2", + "description": "Henry-Kafura information-flow complexity: a module that is both a busy crossroads (high fan-in × fan-out) and large — the most expensive place in the codebase to change.", + "direction": "lower_better", + "formula": "sloc × (fan_in × fan_out)²", + "group": "coupling", + "label": "HK", + "name": "God-object risk", + "short": "HK", + "value_type": "float" + }, + "imports": { + "description": "Qualified paths the file references (≥2 segments, e.g. `http::StatusCode,std::fmt`), production code only.", + "group": "rust", + "label": "Imports", + "value_type": "str" + }, + "items": { + "description": "Number of top-level items (`fn` / `struct` / `enum` / `impl` / `trait` / `mod` / `const` / …) defined in the file — a structural size signal complementary to line counts.", + "group": "rust", + "label": "Items", + "remediation": "Split the file by responsibility into focused modules; move large impls or trait clusters into their own files.", + "value_type": "int" + }, + "length": { + "calc": "n1 + n2", + "description": "Program length — total operator + operand occurrences.", + "direction": "lower_better", + "formula": "n1 + n2", + "group": "halstead", + "label": "Length", + "name": "Total tokens", + "short": "H.len", + "value_type": "float" + }, + "lloc": { + "description": "Logical lines — counts statements, not physical lines. In Rust, measured on production code only (inline `#[cfg(test)]` / `#[test]` tests are excluded, like `sloc`; their lines are `tloc`).", + "group": "loc", + "label": "Logical", + "name": "Logical lines", + "short": "Logical", + "value_type": "int" + }, + "loc": { + "description": "Raw file line count, including blank and comment lines (unlike `sloc`). Large files tend to hold several responsibilities and are harder to review, test, and reuse.", + "group": "loc", + "label": "Lines", + "name": "Total lines", + "remediation": "Split by responsibility into smaller units, extract helpers, and separate data definitions from behavior. For an average breach, break up the largest units first (--top).", + "value_type": "int" + }, + "macros": { + "description": "Names of macros invoked in the file (e.g. `println,vec`), production code only.", + "group": "rust", + "label": "Macros", + "value_type": "str" + }, + "mi": { + "calc": "171 - 5.2*Math.log(volume) - 0.23*cyclomatic - 16.2*Math.log(span_sloc)", + "description": "Maintainability Index (0–100, higher is more maintainable). Derived from Halstead volume, cyclomatic complexity, and SLOC.", + "direction": "higher_better", + "formula": "171 − 5.2·ln(volume) − 0.23·cyclomatic − 16.2·ln(span_sloc)", + "group": "maintainability", + "label": "MI", + "name": "Maintainability index", + "short": "MI", + "value_type": "float" + }, + "mi_sei": { + "calc": "171 - 5.2*Math.log2(volume) - 0.23*cyclomatic - 16.2*Math.log2(span_sloc) + 50*Math.sin(Math.sqrt(cloc / span_sloc * 2.4))", + "description": "SEI variant of the Maintainability Index — adds a bonus for comment density.", + "direction": "higher_better", + "formula": "171 − 5.2·log₂(volume) − 0.23·cyclomatic − 16.2·log₂(span_sloc) + 50·sin(√(cloc ÷ span_sloc × 2.4))", + "group": "maintainability", + "label": "MI (SEI)", + "name": "Maintainability (SEI)", + "short": "MI SEI", + "value_type": "float" + }, + "n1": { + "description": "Total operators (N₁): every operator occurrence counted with repetition (the η₁ tokens, not deduplicated).", + "direction": "lower_better", + "group": "halstead", + "label": "N₁", + "name": "Total operators", + "short": "N₁", + "value_type": "int" + }, + "n2": { + "description": "Total operands (N₂): every operand occurrence counted with repetition (the η₂ texts, not deduplicated).", + "direction": "lower_better", + "group": "halstead", + "label": "N₂", + "name": "Total operands", + "short": "N₂", + "value_type": "int" + }, + "path": { + "label": "Path", + "value_type": "str" + }, + "sloc": { + "description": "Source lines of code — lines with at least one non-whitespace, non-comment character. Blank and comment-only lines are not counted. In Rust, lines inside `#[cfg(test)]` / `#[test]` items are excluded too, so this counts production code only (unlike `loc`, the raw file line count).", + "group": "loc", + "label": "Source", + "name": "Source lines", + "short": "SLOC", + "value_type": "int" + }, + "spaces": { + "description": "Unit count: the source file (1) plus each function / impl / trait / closure space. Feeds `cyclomatic`.", + "direction": "lower_better", + "group": "complexity", + "label": "Spaces", + "name": "Code units", + "short": "Spaces", + "value_type": "int" + }, + "span_sloc": { + "description": "Line span of the unit (end_row − start_row) — the size input the Maintainability Index (`mi` / `mi_sei`) is computed from.", + "direction": "lower_better", + "group": "maintainability", + "label": "Span", + "name": "Line span", + "short": "Span", + "value_type": "int" + }, + "time": { + "calc": "effort / 18", + "description": "Estimated implementation time, in seconds.", + "direction": "lower_better", + "formula": "effort ÷ 18", + "group": "halstead", + "label": "Time", + "name": "Coding time (s)", + "short": "H.time(s)", + "value_type": "float" + }, + "tloc": { + "description": "Test lines of code — the lines inside `#[cfg(test)]` / `#[test]` / `#[bench]` items (Rust), removed before the production metrics are measured. The complement of `sloc`: test code never inflates a file's size, HK, or complexity.", + "group": "loc", + "label": "Test", + "name": "Test lines", + "short": "TLOC", + "value_type": "int" + }, + "types": { + "description": "Names of types defined in the file (struct / enum / type alias).", + "group": "rust", + "label": "Types", + "value_type": "str" + }, + "unsafe": { + "description": "Count of `unsafe` blocks and `unsafe fn`/`impl`/`trait` declarations in production code (test items are excluded). Syntactic count: `unsafe` inside a macro body is not seen, and the figure is not type-checked.", + "direction": "lower_better", + "group": "rust", + "label": "Unsafe", + "name": "Unsafe blocks", + "remediation": "Encapsulate each `unsafe` block behind a safe, documented abstraction with checked invariants; minimize the unsafe surface and cover it with tests.", + "short": "Unsafe", + "value_type": "int" + }, + "version": { + "label": "Version", + "value_type": "str" + }, + "visibility": { + "label": "Visibility", + "value_type": "str" + }, + "vocabulary": { + "calc": "eta1 + eta2", + "description": "Vocabulary — distinct operators + operands.", + "direction": "lower_better", + "formula": "eta1 + eta2", + "group": "halstead", + "label": "Vocabulary", + "name": "Distinct symbols", + "short": "H.vocab", + "value_type": "float" + }, + "volume": { + "calc": "length * Math.log2(vocabulary)", + "description": "Algorithm size in bits, from distinct operators and operands.", + "direction": "lower_better", + "formula": "length × log₂(vocabulary)", + "group": "halstead", + "label": "Volume", + "name": "Code volume", + "short": "H.vol", + "value_type": "float" + } + }, + "node_kinds": { + "external": { + "external": true, + "fill": "#f6e2c0", + "label": "Library", + "plural": "Libraries", + "stroke": "#b3801f" + }, + "file": { + "fill": "#dbe9f4", + "label": "File", + "plural": "Files", + "stroke": "#4d6f9c" + } + }, "nodes": [ - "{target}/src/a.rs", - "{target}/src/b.rs" - ] - } - ], - "edge_attributes": { - "visibility": { - "label": "Visibility", - "value_type": "str" - } - }, - "edge_kinds": { - "contains": { - "description": "Module ownership — the parent declares the child module (`mod foo;` / `pub mod foo;`), so `foo.rs` (or `foo/mod.rs`) belongs to it.
This is the Rust module tree: structure, not a code dependency.
Kept in the data but not drawn on the main map, and excluded from fan-in / fan-out / HK / cycles.", - "flow": false, - "label": "contains" - }, - "reexports": { - "description": "Re-export (`pub use foo::Item;`) — re-publishes another file's item as part of this file's public API (the crate-root / prelude facade, e.g. `lib.rs` doing `pub use access_scope::AccessScope;`).
A facade, not a dependency: excluded from fan-in / fan-out / HK / cycles and not drawn on the main map, like `contains`.
A consumer's `use this_crate::Item` is attributed to the file that defines `Item`, so re-export hubs (`lib.rs` / `mod.rs`) collect no false coupling — the `pub use` is still recorded here so you can see what a file exposes.", - "flow": false, - "label": "reexport" - }, - "super": { - "description": "Namespace pull from an enclosing module — a glob `use` that reaches *up* the module tree (`use super::*`, `use crate::::*`), bringing the parent's items into the child's scope.
Usually structural scope-sugar (a module split across files referring back to itself). But if the child actually uses a parent item brought in by the glob, it IS a real back-dependency — technically a cycle. code-ranker can't tell the two apart without name resolution, so it treats `super` as a **low-priority** cycle and leaves it non-flow: deprioritized next to obvious cross-module cycles.
Kept in the data but not drawn on the main map, and excluded from fan-in / fan-out / HK / cycles — like `contains`.", - "flow": false, - "label": "super" - }, - "uses": { - "description": "Code dependency — this file references an item the target file defines.
Captured from `use path::Item;`, a qualified path (`crate::a::Item`, `other_crate::Item`), or a derive (`#[derive(serde::Serialize)]`).
The path resolves to the file that defines the item (following `pub use` re-exports), so the edge points at the definition, not a re-export hub.
This is the real dependency: it counts toward fan-in / fan-out, Henry-Kafura coupling and cycles.", - "flow": true, - "label": "uses" - } - }, - "edges": [ - { - "kind": "contains", - "source": "{target}/helper/src/lib.rs", - "target": "{target}/helper/src/gadget.rs" - }, - { - "kind": "contains", - "source": "{target}/helper/src/lib.rs", - "target": "{target}/helper/src/widget.rs" - }, - { - "kind": "uses", - "line": 15, - "source": "{target}/src/a.rs", - "target": "ext:serde" - }, - { - "kind": "uses", - "line": 4, - "source": "{target}/src/a.rs", - "target": "{target}/src/b.rs" - }, - { - "kind": "uses", - "line": 6, - "source": "{target}/src/a.rs", - "target": "{target}/src/c.rs" - }, - { - "kind": "uses", - "source": "{target}/src/b.rs", - "target": "ext:once_cell" - }, - { - "kind": "uses", - "line": 8, - "source": "{target}/src/b.rs", - "target": "{target}/src/a.rs" - }, - { - "kind": "contains", - "source": "{target}/src/chain.rs", - "target": "{target}/src/chain/one.rs" - }, - { - "kind": "contains", - "source": "{target}/src/chain.rs", - "target": "{target}/src/chain/three.rs" - }, - { - "kind": "contains", - "source": "{target}/src/chain.rs", - "target": "{target}/src/chain/two.rs" - }, - { - "kind": "uses", - "line": 2, - "source": "{target}/src/chain/one.rs", - "target": "{target}/src/chain/two.rs" - }, - { - "kind": "uses", - "line": 2, - "source": "{target}/src/chain/three.rs", - "target": "{target}/src/chain/one.rs" - }, - { - "kind": "uses", - "line": 2, - "source": "{target}/src/chain/two.rs", - "target": "{target}/src/chain/three.rs" - }, - { - "kind": "uses", - "line": 13, - "source": "{target}/src/cross.rs", - "target": "{target}/helper/src/gadget.rs" - }, - { - "kind": "uses", - "line": 15, - "source": "{target}/src/cross.rs", - "target": "{target}/helper/src/lib.rs" - }, - { - "kind": "uses", - "line": 11, - "source": "{target}/src/cross.rs", - "target": "{target}/helper/src/widget.rs" - }, - { - "kind": "contains", - "source": "{target}/src/cycle_examples.rs", - "target": "{target}/src/cycle_examples/reex_hub.rs" - }, - { - "kind": "contains", - "source": "{target}/src/cycle_examples.rs", - "target": "{target}/src/cycle_examples/reex_spoke.rs" - }, - { - "kind": "contains", - "source": "{target}/src/cycle_examples.rs", - "target": "{target}/src/cycle_examples/sup_loose.rs" - }, - { - "kind": "contains", - "source": "{target}/src/cycle_examples.rs", - "target": "{target}/src/cycle_examples/sup_parent.rs" - }, - { - "kind": "reexports", - "line": 8, - "source": "{target}/src/cycle_examples/reex_hub.rs", - "target": "{target}/src/cycle_examples/reex_spoke.rs", - "visibility": "public" - }, - { - "kind": "uses", - "line": 6, - "source": "{target}/src/cycle_examples/reex_spoke.rs", - "target": "{target}/src/cycle_examples/reex_hub.rs" - }, - { - "kind": "contains", - "source": "{target}/src/cycle_examples/sup_loose.rs", - "target": "{target}/src/cycle_examples/sup_loose/child.rs" - }, - { - "kind": "uses", - "line": 8, - "source": "{target}/src/cycle_examples/sup_loose.rs", - "target": "{target}/src/cycle_examples/sup_loose/child.rs" - }, - { - "kind": "super", - "line": 9, - "source": "{target}/src/cycle_examples/sup_loose/child.rs", - "target": "{target}/src/cycle_examples/sup_loose.rs" - }, - { - "kind": "contains", - "source": "{target}/src/cycle_examples/sup_parent.rs", - "target": "{target}/src/cycle_examples/sup_parent/child.rs" - }, - { - "kind": "uses", - "line": 16, - "source": "{target}/src/cycle_examples/sup_parent.rs", - "target": "{target}/src/cycle_examples/sup_parent/child.rs" - }, - { - "kind": "super", - "line": 11, - "source": "{target}/src/cycle_examples/sup_parent/child.rs", - "target": "{target}/src/cycle_examples/sup_parent.rs" - }, - { - "kind": "uses", - "source": "{target}/src/derives.rs", - "target": "ext:serde" - }, - { - "kind": "uses", - "line": 10, - "source": "{target}/src/foo.rs", - "target": "{target}/src/b.rs" - }, - { - "kind": "contains", - "source": "{target}/src/foo.rs", - "target": "{target}/src/foo/bar.rs" - }, - { - "kind": "super", - "line": 13, - "source": "{target}/src/foo/bar.rs", - "target": "{target}/src/foo.rs" - }, - { - "kind": "contains", - "source": "{target}/src/lib.rs", - "target": "{target}/src/a.rs" - }, - { - "kind": "reexports", - "line": 42, - "source": "{target}/src/lib.rs", - "target": "{target}/src/a.rs", - "visibility": "public" - }, - { - "kind": "uses", - "line": 64, - "source": "{target}/src/lib.rs", - "target": "{target}/src/a.rs" - }, - { - "kind": "contains", - "source": "{target}/src/lib.rs", - "target": "{target}/src/b.rs" - }, - { - "kind": "uses", - "line": 65, - "source": "{target}/src/lib.rs", - "target": "{target}/src/b.rs" - }, - { - "kind": "contains", - "source": "{target}/src/lib.rs", - "target": "{target}/src/c.rs" - }, - { - "kind": "contains", - "source": "{target}/src/lib.rs", - "target": "{target}/src/chain.rs" - }, - { - "kind": "contains", - "source": "{target}/src/lib.rs", - "target": "{target}/src/complex.rs" - }, - { - "kind": "contains", - "source": "{target}/src/lib.rs", - "target": "{target}/src/cross.rs" - }, - { - "kind": "contains", - "source": "{target}/src/lib.rs", - "target": "{target}/src/cycle_examples.rs" - }, - { - "kind": "contains", - "source": "{target}/src/lib.rs", - "target": "{target}/src/derives.rs" - }, - { - "kind": "contains", - "source": "{target}/src/lib.rs", - "target": "{target}/src/foo.rs" - }, - { - "kind": "uses", - "source": "{target}/src/lib.rs", - "target": "{target}/src/foo.rs" - }, - { - "kind": "contains", - "source": "{target}/src/lib.rs", - "target": "{target}/src/macros.rs" - }, - { - "kind": "contains", - "source": "{target}/src/lib.rs", - "target": "{target}/src/relocated/custom.rs" - }, - { - "kind": "uses", - "line": 7, - "source": "{target}/src/relocated/custom.rs", - "target": "{target}/src/c.rs" - } - ], - "node_attributes": { - "args": { - "description": "Number of function / closure arguments.", - "direction": "lower_better", - "group": "complexity", - "label": "Args", - "name": "Arguments", - "short": "Args", - "value_type": "int" - }, - "attrs": { - "description": "Names of attributes (other than `derive`) applied in the file (e.g. `tokio,serde`), production code only.", - "group": "rust", - "label": "Attributes", - "value_type": "str" - }, - "blank": { - "description": "Empty or whitespace-only lines. In Rust, measured on production code only (inline `#[cfg(test)]` / `#[test]` tests are excluded, like `sloc`; their lines are `tloc`).", - "group": "loc", - "label": "Blank", - "name": "Blank lines", - "short": "Blank", - "value_type": "int" - }, - "branches": { - "description": "Decision points: if / for / while / loop / match arm / try / && / ||. Feeds `cyclomatic`.", - "direction": "lower_better", - "group": "complexity", - "label": "Branches", - "name": "Decision points", - "short": "Branches", - "value_type": "int" - }, - "bugs": { - "calc": "effort ** (2/3) / 3000", - "description": "Estimated delivered bugs — a rough predictor of defect density.", - "direction": "lower_better", - "formula": "effort^⅔ ÷ 3000", - "group": "halstead", - "label": "Bugs", - "name": "Estimated bugs", - "short": "H.bugs", - "value_type": "float" - }, - "cloc": { - "description": "Comment-only lines (inline comments on code lines are not counted). In Rust, measured on production code only (inline `#[cfg(test)]` / `#[test]` tests are excluded, like `sloc`; their lines are `tloc`).", - "group": "loc", - "label": "Comments", - "name": "Comment lines", - "short": "Comments", - "value_type": "int" - }, - "closures": { - "description": "Number of closures defined in the unit.", - "direction": "lower_better", - "group": "complexity", - "label": "Closures", - "name": "Closures defined", - "short": "Closures", - "value_type": "int" - }, - "cognitive": { - "description": "How hard the code is for a human to follow — not just how many paths it has.
Like `cyclomatic` it adds +1 for each break in linear flow (`if`, `else`, `match`, loops, `catch`, chained `&&` / `||`), but it also adds an extra +1 for every level of nesting: an `if` inside a loop inside an `if` costs far more than three flat `if`s.
That nesting penalty is the point — deeply indented logic is what actually strains a reader, so a high `cognitive` next to a modest `cyclomatic` flags tangled, hard-to-read code.
Summed across every function in the file.", - "direction": "lower_better", - "group": "complexity", - "label": "Cognitive", - "name": "Cognitive complexity", - "short": "Cognitive", - "value_type": "int" - }, - "crate": { - "label": "Crate", - "value_type": "str" - }, - "cycle": { - "description": "Cycle kind this node participates in.", - "group": "coupling", - "label": "Cycle", - "name": "Dependency cycle", - "short": "Cycle", - "value_type": "str" - }, - "cyclomatic": { - "calc": "spaces + branches", - "description": "Number of independent paths through the code — roughly the minimum number of test cases needed to cover every branch.
A function starts at 1 and gains +1 per decision point: each `if` / `else if`, every `match` / `switch` arm, every loop, and each `&&` / `||` in a condition.
Summed across every function in the file, so it grows with both size and branching — the file's total branching burden.
Counts paths only, ignoring how deeply they nest. For a readability-weighted view see `cognitive`.", - "direction": "lower_better", - "formula": "spaces + branches", - "group": "complexity", - "label": "Cyclomatic", - "name": "Cyclomatic complexity", - "omit_at": 1.0, - "short": "Cyclomatic", - "value_type": "int" - }, - "derives": { - "description": "Names of the `#[derive(...)]` traits used in the file (e.g. `Serialize,Debug`), production code only.", - "group": "rust", - "label": "Derives", - "value_type": "str" - }, - "effort": { - "calc": "(eta1 / 2) * (n2 / eta2) * volume", - "description": "Mental effort to implement the algorithm.", - "direction": "lower_better", - "formula": "(eta1 ÷ 2) × (n2 ÷ eta2) × volume", - "group": "halstead", - "label": "Effort", - "name": "Implementation effort", - "short": "H.effort", - "value_type": "float" - }, - "eta1": { - "description": "Distinct operators (η₁): the count of unique operator token kinds. Rust counts punctuation & delimiters (`( { [ , . ; -> => ? .. ..=`), arithmetic / bitwise / comparison / assignment operators (`+ - * / % & | ^ << >> == != < > <= >= && || += -= *= /= %= &= |= ^= <<= >>=`), the keywords `async await continue for if let loop match move return unsafe while fn`, `mut` (mutable_specifier) and primitive types, plus the context-sensitive `|| / !`.", - "direction": "lower_better", - "group": "halstead", - "label": "η₁", - "name": "Unique operators", - "short": "η₁", - "value_type": "int" - }, - "eta2": { - "description": "Distinct operands (η₂): the count of unique operand texts. Rust counts identifiers, `self`, the `_` wildcard, and literals — string, raw-string, integer, float, boolean and char.", - "direction": "lower_better", - "group": "halstead", - "label": "η₂", - "name": "Unique operands", - "short": "η₂", - "value_type": "int" - }, - "exits": { - "description": "Number of exit points (return/throw) in the unit.", - "direction": "lower_better", - "group": "complexity", - "label": "Exits", - "name": "Exit points", - "short": "Exits", - "value_type": "int" - }, - "external": { - "label": "External", - "value_type": "bool" - }, - "fan_in": { - "description": "Many other units depend on this one, making it risky to change and a single point of failure — though some hubs (shared types) carry high fan-in legitimately.", - "group": "coupling", - "label": "Fan-in", - "name": "Incoming dependencies", - "short": "Fan-in", - "value_type": "int" - }, - "fan_out": { - "description": "This unit depends on many others, so it breaks when any of them change and is hard to test in isolation.", - "group": "coupling", - "label": "Fan-out", - "name": "Outgoing dependencies", - "short": "Fan-out", - "value_type": "int" - }, - "fan_out_external": { - "description": "Number of distinct external libraries this node depends on.", - "group": "coupling", - "label": "Fan-out (external)", - "name": "External dependencies", - "short": "Fan-out (external)", - "value_type": "int" - }, - "hk": { - "abbreviate": true, - "calc": "sloc * (fan_in * fan_out) ** 2", - "description": "Henry-Kafura information-flow complexity: a module that is both a busy crossroads (high fan-in × fan-out) and large — the most expensive place in the codebase to change.", - "direction": "lower_better", - "formula": "sloc × (fan_in × fan_out)²", - "group": "coupling", - "label": "HK", - "name": "God-object risk", - "short": "HK", - "value_type": "float" - }, - "imports": { - "description": "Qualified paths the file references (≥2 segments, e.g. `http::StatusCode,std::fmt`), production code only.", - "group": "rust", - "label": "Imports", - "value_type": "str" - }, - "items": { - "description": "Number of top-level items (`fn` / `struct` / `enum` / `impl` / `trait` / `mod` / `const` / …) defined in the file — a structural size signal complementary to line counts.", - "group": "rust", - "label": "Items", - "remediation": "Split the file by responsibility into focused modules; move large impls or trait clusters into their own files.", - "value_type": "int" - }, - "length": { - "calc": "n1 + n2", - "description": "Program length — total operator + operand occurrences.", - "direction": "lower_better", - "formula": "n1 + n2", - "group": "halstead", - "label": "Length", - "name": "Total tokens", - "short": "H.len", - "value_type": "float" - }, - "lloc": { - "description": "Logical lines — counts statements, not physical lines. In Rust, measured on production code only (inline `#[cfg(test)]` / `#[test]` tests are excluded, like `sloc`; their lines are `tloc`).", - "group": "loc", - "label": "Logical", - "name": "Logical lines", - "short": "Logical", - "value_type": "int" - }, - "loc": { - "description": "Raw file line count, including blank and comment lines (unlike `sloc`). Large files tend to hold several responsibilities and are harder to review, test, and reuse.", - "group": "loc", - "label": "Lines", - "name": "Total lines", - "remediation": "Split by responsibility into smaller units, extract helpers, and separate data definitions from behavior. For an average breach, break up the largest units first (--top).", - "value_type": "int" - }, - "macros": { - "description": "Names of macros invoked in the file (e.g. `println,vec`), production code only.", - "group": "rust", - "label": "Macros", - "value_type": "str" - }, - "mi": { - "calc": "171 - 5.2*Math.log(volume) - 0.23*cyclomatic - 16.2*Math.log(span_sloc)", - "description": "Maintainability Index (0–100, higher is more maintainable). Derived from Halstead volume, cyclomatic complexity, and SLOC.", - "direction": "higher_better", - "formula": "171 − 5.2·ln(volume) − 0.23·cyclomatic − 16.2·ln(span_sloc)", - "group": "maintainability", - "label": "MI", - "name": "Maintainability index", - "short": "MI", - "value_type": "float" - }, - "mi_sei": { - "calc": "171 - 5.2*Math.log2(volume) - 0.23*cyclomatic - 16.2*Math.log2(span_sloc) + 50*Math.sin(Math.sqrt(cloc / span_sloc * 2.4))", - "description": "SEI variant of the Maintainability Index — adds a bonus for comment density.", - "direction": "higher_better", - "formula": "171 − 5.2·log₂(volume) − 0.23·cyclomatic − 16.2·log₂(span_sloc) + 50·sin(√(cloc ÷ span_sloc × 2.4))", - "group": "maintainability", - "label": "MI (SEI)", - "name": "Maintainability (SEI)", - "short": "MI SEI", - "value_type": "float" - }, - "n1": { - "description": "Total operators (N₁): every operator occurrence counted with repetition (the η₁ tokens, not deduplicated).", - "direction": "lower_better", - "group": "halstead", - "label": "N₁", - "name": "Total operators", - "short": "N₁", - "value_type": "int" - }, - "n2": { - "description": "Total operands (N₂): every operand occurrence counted with repetition (the η₂ texts, not deduplicated).", - "direction": "lower_better", - "group": "halstead", - "label": "N₂", - "name": "Total operands", - "short": "N₂", - "value_type": "int" - }, - "path": { - "label": "Path", - "value_type": "str" - }, - "sloc": { - "description": "Source lines of code — lines with at least one non-whitespace, non-comment character. Blank and comment-only lines are not counted. In Rust, lines inside `#[cfg(test)]` / `#[test]` items are excluded too, so this counts production code only (unlike `loc`, the raw file line count).", - "group": "loc", - "label": "Source", - "name": "Source lines", - "short": "SLOC", - "value_type": "int" - }, - "spaces": { - "description": "Unit count: the source file (1) plus each function / impl / trait / closure space. Feeds `cyclomatic`.", - "direction": "lower_better", - "group": "complexity", - "label": "Spaces", - "name": "Code units", - "short": "Spaces", - "value_type": "int" - }, - "span_sloc": { - "description": "Line span of the unit (end_row − start_row) — the size input the Maintainability Index (`mi` / `mi_sei`) is computed from.", - "direction": "lower_better", - "group": "maintainability", - "label": "Span", - "name": "Line span", - "short": "Span", - "value_type": "int" - }, - "time": { - "calc": "effort / 18", - "description": "Estimated implementation time, in seconds.", - "direction": "lower_better", - "formula": "effort ÷ 18", - "group": "halstead", - "label": "Time", - "name": "Coding time (s)", - "short": "H.time(s)", - "value_type": "float" - }, - "tloc": { - "description": "Test lines of code — the lines inside `#[cfg(test)]` / `#[test]` / `#[bench]` items (Rust), removed before the production metrics are measured. The complement of `sloc`: test code never inflates a file's size, HK, or complexity.", - "group": "loc", - "label": "Test", - "name": "Test lines", - "short": "TLOC", - "value_type": "int" - }, - "types": { - "description": "Names of types defined in the file (struct / enum / type alias).", - "group": "rust", - "label": "Types", - "value_type": "str" - }, - "unsafe": { - "description": "Count of `unsafe` blocks and `unsafe fn`/`impl`/`trait` declarations in production code (test items are excluded). Syntactic count: `unsafe` inside a macro body is not seen, and the figure is not type-checked.", - "direction": "lower_better", - "group": "rust", - "label": "Unsafe", - "name": "Unsafe blocks", - "remediation": "Encapsulate each `unsafe` block behind a safe, documented abstraction with checked invariants; minimize the unsafe surface and cover it with tests.", - "short": "Unsafe", - "value_type": "int" - }, - "version": { - "label": "Version", - "value_type": "str" - }, - "visibility": { - "label": "Visibility", - "value_type": "str" - }, - "vocabulary": { - "calc": "eta1 + eta2", - "description": "Vocabulary — distinct operators + operands.", - "direction": "lower_better", - "formula": "eta1 + eta2", - "group": "halstead", - "label": "Vocabulary", - "name": "Distinct symbols", - "short": "H.vocab", - "value_type": "float" - }, - "volume": { - "calc": "length * Math.log2(vocabulary)", - "description": "Algorithm size in bits, from distinct operators and operands.", - "direction": "lower_better", - "formula": "length × log₂(vocabulary)", - "group": "halstead", - "label": "Volume", - "name": "Code volume", - "short": "H.vol", - "value_type": "float" + { + "external": true, + "id": "ext:once_cell", + "kind": "external", + "name": "once_cell", + "path": "{registry}/once_cell-1.21.4", + "version": "1.21.4" + }, + { + "external": true, + "id": "ext:serde", + "kind": "external", + "name": "serde", + "path": "{registry}/serde-1.0.228", + "version": "1.0.228" + }, + { + "blank": 1, + "bugs": 0.00447, + "cloc": 2, + "crate": "helper", + "cyclomatic": 2, + "effort": 49.128, + "eta1": 5, + "eta2": 2, + "exits": 1, + "fan_in": 1, + "id": "{target}/helper/src/gadget.rs", + "items": 1, + "kind": "file", + "length": 7, + "loc": 6, + "mi": 126.027, + "mi_sei": 145.313, + "n1": 5, + "n2": 2, + "name": "gadget.rs", + "sloc": 3, + "spaces": 2, + "span_sloc": 6, + "time": 2.729, + "visibility": "public", + "vocabulary": 7, + "volume": 19.651 + }, + { + "blank": 2, + "bugs": 0.00376, + "cloc": 7, + "crate": "helper", + "effort": 37.899, + "eta1": 3, + "eta2": 4, + "fan_in": 1, + "id": "{target}/helper/src/lib.rs", + "items": 3, + "kind": "file", + "length": 9, + "loc": 12, + "mi": 113.721, + "mi_sei": 134.757, + "n1": 5, + "n2": 4, + "name": "lib.rs", + "sloc": 3, + "spaces": 1, + "span_sloc": 12, + "time": 2.105, + "visibility": "public", + "vocabulary": 7, + "volume": 25.266 + }, + { + "blank": 2, + "bugs": 0.00447, + "cloc": 2, + "crate": "helper", + "cyclomatic": 2, + "effort": 49.128, + "eta1": 5, + "eta2": 2, + "exits": 1, + "fan_in": 1, + "id": "{target}/helper/src/widget.rs", + "items": 2, + "kind": "file", + "length": 7, + "loc": 8, + "mi": 121.366, + "mi_sei": 134.569, + "n1": 5, + "n2": 2, + "name": "widget.rs", + "sloc": 4, + "spaces": 2, + "span_sloc": 8, + "time": 2.729, + "types": "Widget", + "visibility": "public", + "vocabulary": 7, + "volume": 19.651 + }, + { + "blank": 5, + "bugs": 0.0896, + "cloc": 11, + "crate": "rust-sample", + "cycle": "mutual", + "cyclomatic": 2, + "derives": "Serialize", + "effort": 4413.97, + "eta1": 15, + "eta2": 17, + "exits": 1, + "fan_in": 2, + "fan_out": 2, + "fan_out_external": 1, + "hk": 224, + "id": "{target}/src/a.rs", + "imports": "HashMap::new,helpers::offset", + "items": 2, + "kind": "file", + "length": 69, + "lloc": 1, + "loc": 30, + "mi": 85.054, + "mi_sei": 87.531, + "n1": 40, + "n2": 29, + "name": "a.rs", + "sloc": 14, + "spaces": 2, + "span_sloc": 30, + "time": 245.22, + "types": "Alpha", + "visibility": "public", + "vocabulary": 32, + "volume": 345 + }, + { + "blank": 4, + "bugs": 0.0521, + "cloc": 15, + "closures": 1, + "crate": "rust-sample", + "cycle": "mutual", + "cyclomatic": 4, + "effort": 1958.647, + "eta1": 14, + "eta2": 13, + "exits": 2, + "fan_in": 3, + "fan_out": 1, + "fan_out_external": 1, + "hk": 81, + "id": "{target}/src/b.rs", + "imports": "once_cell::sync::Lazy,once_cell::sync::Lazy::new", + "items": 2, + "kind": "file", + "length": 45, + "lloc": 2, + "loc": 28, + "macros": "println,pull_in_c", + "mi": 88.195, + "mi_sei": 97.249, + "n1": 28, + "n2": 17, + "name": "b.rs", + "sloc": 9, + "spaces": 4, + "span_sloc": 28, + "time": 108.813, + "visibility": "public", + "vocabulary": 27, + "volume": 213.969 + }, + { + "attrs": "test", + "blank": 3, + "bugs": 0.00867, + "cloc": 9, + "crate": "rust-sample", + "cyclomatic": 3, + "effort": 132.877, + "eta1": 5, + "eta2": 5, + "exits": 2, + "fan_in": 2, + "id": "{target}/src/c.rs", + "items": 3, + "kind": "file", + "length": 16, + "loc": 32, + "macros": "assert_eq", + "mi": 101.118, + "mi_sei": 113.588, + "n1": 11, + "n2": 5, + "name": "c.rs", + "sloc": 8, + "spaces": 3, + "span_sloc": 20, + "time": 7.382, + "tloc": 12, + "visibility": "public", + "vocabulary": 10, + "volume": 53.15 + }, + { + "bugs": 0.0011, + "cloc": 5, + "crate": "rust-sample", + "effort": 6, + "eta1": 1, + "eta2": 3, + "id": "{target}/src/chain.rs", + "items": 3, + "kind": "file", + "length": 6, + "loc": 8, + "mi": 124.161, + "mi_sei": 150.564, + "n1": 3, + "n2": 3, + "name": "chain.rs", + "sloc": 3, + "spaces": 1, + "span_sloc": 8, + "time": 0.333, + "visibility": "public", + "vocabulary": 4, + "volume": 12 + }, + { + "blank": 1, + "bugs": 0.011, + "cloc": 1, + "crate": "rust-sample", + "cycle": "chain", + "cyclomatic": 2, + "effort": 190.195, + "eta1": 6, + "eta2": 3, + "exits": 1, + "fan_in": 1, + "fan_out": 1, + "hk": 4, + "id": "{target}/src/chain/one.rs", + "items": 1, + "kind": "file", + "length": 12, + "loc": 6, + "mi": 122.592, + "mi_sei": 130.923, + "n1": 7, + "n2": 5, + "name": "one.rs", + "sloc": 4, + "spaces": 2, + "span_sloc": 6, + "time": 10.566, + "visibility": "public", + "vocabulary": 9, + "volume": 38.039 + }, + { + "blank": 1, + "bugs": 0.011, + "cloc": 1, + "crate": "rust-sample", + "cycle": "chain", + "cyclomatic": 2, + "effort": 190.195, + "eta1": 6, + "eta2": 3, + "exits": 1, + "fan_in": 1, + "fan_out": 1, + "hk": 4, + "id": "{target}/src/chain/three.rs", + "items": 1, + "kind": "file", + "length": 12, + "loc": 6, + "mi": 122.592, + "mi_sei": 130.923, + "n1": 7, + "n2": 5, + "name": "three.rs", + "sloc": 4, + "spaces": 2, + "span_sloc": 6, + "time": 10.566, + "visibility": "public", + "vocabulary": 9, + "volume": 38.039 + }, + { + "blank": 1, + "bugs": 0.011, + "cloc": 1, + "crate": "rust-sample", + "cycle": "chain", + "cyclomatic": 2, + "effort": 190.195, + "eta1": 6, + "eta2": 3, + "exits": 1, + "fan_in": 1, + "fan_out": 1, + "hk": 4, + "id": "{target}/src/chain/two.rs", + "items": 1, + "kind": "file", + "length": 12, + "loc": 6, + "mi": 122.592, + "mi_sei": 130.923, + "n1": 7, + "n2": 5, + "name": "two.rs", + "sloc": 4, + "spaces": 2, + "span_sloc": 6, + "time": 10.566, + "visibility": "public", + "vocabulary": 9, + "volume": 38.039 + }, + { + "args": 6, + "blank": 2, + "branches": 4, + "bugs": 0.158, + "cloc": 17, + "closures": 1, + "cognitive": 5, + "crate": "rust-sample", + "cyclomatic": 8, + "effort": 10375, + "eta1": 20, + "eta2": 12, + "exits": 4, + "id": "{target}/src/complex.rs", + "items": 2, + "kind": "file", + "length": 83, + "lloc": 6, + "loc": 32, + "mi": 81.668, + "mi_sei": 88.138, + "n1": 53, + "n2": 30, + "name": "complex.rs", + "sloc": 15, + "spaces": 4, + "span_sloc": 32, + "time": 576.388, + "unsafe": 1, + "visibility": "public", + "vocabulary": 32, + "volume": 415 + }, + { + "blank": 2, + "bugs": 0.0331, + "cloc": 11, + "crate": "rust-sample", + "cyclomatic": 2, + "effort": 991.183, + "eta1": 10, + "eta2": 9, + "exits": 1, + "fan_out": 3, + "id": "{target}/src/cross.rs", + "items": 1, + "kind": "file", + "length": 30, + "lloc": 1, + "loc": 20, + "mi": 96.801, + "mi_sei": 109.773, + "n1": 16, + "n2": 14, + "name": "cross.rs", + "sloc": 7, + "spaces": 2, + "span_sloc": 20, + "time": 55.065, + "visibility": "public", + "vocabulary": 19, + "volume": 127.437 + }, + { + "blank": 1, + "bugs": 0.00147, + "cloc": 16, + "crate": "rust-sample", + "effort": 9.287, + "eta1": 1, + "eta2": 4, + "id": "{target}/src/cycle_examples.rs", + "items": 4, + "kind": "file", + "length": 8, + "loc": 21, + "mi": 106.255, + "mi_sei": 126.505, + "n1": 4, + "n2": 4, + "name": "cycle_examples.rs", + "sloc": 4, + "spaces": 1, + "span_sloc": 21, + "time": 0.515, + "visibility": "public", + "vocabulary": 5, + "volume": 18.575 + }, + { + "blank": 2, + "bugs": 0.000974, + "cloc": 7, + "crate": "rust-sample", + "effort": 5, + "eta1": 1, + "eta2": 3, + "fan_in": 1, + "id": "{target}/src/cycle_examples/reex_hub.rs", + "items": 1, + "kind": "file", + "length": 5, + "loc": 11, + "mi": 119.95, + "mi_sei": 144.674, + "n1": 2, + "n2": 3, + "name": "reex_hub.rs", + "sloc": 2, + "spaces": 1, + "span_sloc": 11, + "time": 0.277, + "types": "Hub", + "visibility": "public", + "vocabulary": 4, + "volume": 10 + }, + { + "args": 1, + "blank": 3, + "bugs": 0.00736, + "cloc": 4, + "crate": "rust-sample", + "cyclomatic": 2, + "effort": 103.782, + "eta1": 5, + "eta2": 6, + "exits": 1, + "fan_out": 1, + "id": "{target}/src/cycle_examples/reex_spoke.rs", + "items": 2, + "kind": "file", + "length": 12, + "loc": 12, + "mi": 110.909, + "mi_sei": 123.503, + "n1": 6, + "n2": 6, + "name": "reex_spoke.rs", + "sloc": 5, + "spaces": 2, + "span_sloc": 12, + "time": 5.765, + "types": "Widget", + "visibility": "public", + "vocabulary": 11, + "volume": 41.513 + }, + { + "blank": 4, + "bugs": 0.00959, + "cloc": 4, + "crate": "rust-sample", + "cyclomatic": 2, + "effort": 154.533, + "eta1": 5, + "eta2": 4, + "exits": 1, + "fan_out": 1, + "id": "{target}/src/cycle_examples/sup_loose.rs", + "items": 3, + "kind": "file", + "length": 13, + "loc": 14, + "mi": 108.45, + "mi_sei": 117.795, + "n1": 7, + "n2": 6, + "name": "sup_loose.rs", + "sloc": 6, + "spaces": 2, + "span_sloc": 14, + "time": 8.585, + "types": "Bough", + "visibility": "public", + "vocabulary": 9, + "volume": 41.209 + }, + { + "blank": 2, + "cloc": 7, + "crate": "rust-sample", + "eta1": 2, + "fan_in": 1, + "id": "{target}/src/cycle_examples/sup_loose/child.rs", + "items": 1, + "kind": "file", + "length": 3, + "loc": 11, + "mi": 126.211, + "mi_sei": 153.706, + "n1": 3, + "name": "child.rs", + "sloc": 2, + "spaces": 1, + "span_sloc": 11, + "types": "Pip", + "visibility": "public", + "vocabulary": 2, + "volume": 3 + }, + { + "blank": 4, + "bugs": 0.00959, + "cloc": 12, + "crate": "rust-sample", + "cyclomatic": 2, + "effort": 154.533, + "eta1": 5, + "eta2": 4, + "exits": 1, + "fan_out": 1, + "id": "{target}/src/cycle_examples/sup_parent.rs", + "items": 3, + "kind": "file", + "length": 13, + "loc": 22, + "mi": 101.128, + "mi_sei": 115.917, + "n1": 7, + "n2": 6, + "name": "sup_parent.rs", + "sloc": 6, + "spaces": 2, + "span_sloc": 22, + "time": 8.585, + "types": "Nest", + "visibility": "public", + "vocabulary": 9, + "volume": 41.209 + }, + { + "args": 1, + "blank": 3, + "bugs": 0.00488, + "cloc": 9, + "crate": "rust-sample", + "cyclomatic": 2, + "effort": 56.147, + "eta1": 5, + "eta2": 2, + "fan_in": 1, + "id": "{target}/src/cycle_examples/sup_parent/child.rs", + "items": 2, + "kind": "file", + "length": 8, + "loc": 15, + "mi": 110.488, + "mi_sei": 130.506, + "n1": 6, + "n2": 2, + "name": "child.rs", + "sloc": 3, + "spaces": 2, + "span_sloc": 15, + "time": 3.119, + "types": "Chick", + "visibility": "public", + "vocabulary": 7, + "volume": 22.458 + }, + { + "attrs": "test", + "blank": 2, + "bugs": 0.0051, + "cloc": 9, + "crate": "rust-sample", + "derives": "Serialize", + "effort": 60, + "eta1": 5, + "eta2": 3, + "fan_out_external": 1, + "id": "{target}/src/derives.rs", + "imports": "crate::derives::OnlyDerived,serde::Serialize", + "items": 2, + "kind": "file", + "length": 8, + "loc": 23, + "macros": "assert_eq", + "mi": 110.373, + "mi_sei": 130.238, + "n1": 5, + "n2": 3, + "name": "derives.rs", + "sloc": 4, + "spaces": 1, + "span_sloc": 15, + "time": 3.333, + "tloc": 8, + "types": "OnlyDerived", + "visibility": "public", + "vocabulary": 8, + "volume": 24 + }, + { + "blank": 3, + "bugs": 0.0123, + "cloc": 12, + "crate": "rust-sample", + "cyclomatic": 2, + "effort": 225.852, + "eta1": 7, + "eta2": 5, + "exits": 1, + "fan_in": 1, + "fan_out": 1, + "hk": 5, + "id": "{target}/src/foo.rs", + "items": 2, + "kind": "file", + "length": 15, + "loc": 20, + "mi": 101.288, + "mi_sei": 117.232, + "n1": 9, + "n2": 6, + "name": "foo.rs", + "sloc": 5, + "spaces": 2, + "span_sloc": 20, + "time": 12.547, + "visibility": "private", + "vocabulary": 12, + "volume": 53.774 + }, + { + "blank": 1, + "bugs": 0.01, + "cloc": 14, + "crate": "rust-sample", + "cyclomatic": 2, + "effort": 166.052, + "eta1": 8, + "eta2": 3, + "exits": 1, + "id": "{target}/src/foo/bar.rs", + "items": 1, + "kind": "file", + "length": 12, + "loc": 19, + "mi": 103.464, + "mi_sei": 122.326, + "n1": 9, + "n2": 3, + "name": "bar.rs", + "sloc": 4, + "spaces": 2, + "span_sloc": 19, + "time": 9.225, + "visibility": "public", + "vocabulary": 11, + "volume": 41.513 + }, + { + "attrs": "macro_use,path,test", + "blank": 7, + "bugs": 0.0355, + "cloc": 34, + "crate": "rust-sample", + "cyclomatic": 2, + "effort": 1099.872, + "eta1": 9, + "eta2": 19, + "exits": 1, + "fan_out": 3, + "id": "{target}/src/lib.rs", + "imports": "foo::run", + "items": 14, + "kind": "file", + "length": 46, + "lloc": 1, + "loc": 71, + "macros": "assert_eq,make_answer", + "mi": 76.137, + "mi_sei": 80.309, + "n1": 25, + "n2": 21, + "name": "lib.rs", + "sloc": 19, + "spaces": 2, + "span_sloc": 60, + "time": 61.104, + "tloc": 11, + "visibility": "public", + "vocabulary": 28, + "volume": 221.138 + }, + { + "blank": 2, + "bugs": 0.0174, + "cloc": 7, + "crate": "rust-sample", + "effort": 377.753, + "eta1": 7, + "eta2": 6, + "id": "{target}/src/macros.rs", + "items": 2, + "kind": "file", + "length": 25, + "loc": 22, + "macros": "macro_rules", + "mi": 97.153, + "mi_sei": 102.903, + "n1": 18, + "n2": 7, + "name": "macros.rs", + "sloc": 13, + "spaces": 1, + "span_sloc": 22, + "time": 20.986, + "visibility": "private", + "vocabulary": 13, + "volume": 92.51 + }, + { + "blank": 2, + "bugs": 0.0112, + "cloc": 5, + "crate": "rust-sample", + "cyclomatic": 2, + "effort": 196.755, + "eta1": 7, + "eta2": 4, + "exits": 1, + "fan_out": 1, + "id": "{target}/src/relocated/custom.rs", + "items": 1, + "kind": "file", + "length": 13, + "loc": 11, + "mi": 111.902, + "mi_sei": 129.176, + "n1": 8, + "n2": 5, + "name": "custom.rs", + "sloc": 4, + "spaces": 2, + "span_sloc": 11, + "time": 10.93, + "visibility": "private", + "vocabulary": 11, + "volume": 44.972 + } + ], + "stats": { + "blank": 2.5, + "bugs": 0.0214, + "cloc": 8.88, + "cognitive": 5, + "cyclomatic": 2.5, + "effort": 883.082, + "fan_in": 1.307, + "fan_out": 1.416, + "hk": 53.666, + "length": 19.56, + "mi": 107.583, + "mi_sei": 121.961, + "sloc": 6.2, + "time": 49.059, + "tloc": 10.333, + "unsafe": 1, + "vocabulary": 12.08, + "volume": 80.044 + }, + "ui": { + "card": [ + "hk", + "sloc" + ], + "columns": [ + "kind", + "cycle", + "sloc", + "hk", + "fan_in", + "fan_out", + "bugs", + "cyclomatic", + "cognitive", + "mi", + "mi_sei", + "lloc", + "cloc", + "blank", + "tloc", + "unsafe" + ], + "default_sort": "cycle", + "filter": [ + "cycle" + ], + "grouping": { + "key": "crate" + }, + "size": [ + "sloc", + "hk" + ], + "sort": [ + "cycle", + "sloc", + "hk", + "fan_in", + "fan_out", + "bugs", + "cyclomatic", + "cognitive", + "mi", + "mi_sei", + "lloc", + "cloc", + "blank", + "tloc", + "unsafe" + ], + "summary": [ + "sloc", + "hk", + "fan_in", + "fan_out", + "bugs", + "cyclomatic", + "cognitive", + "mi", + "mi_sei", + "lloc", + "cloc", + "blank", + "tloc", + "unsafe" + ] + } } }, - "node_kinds": { - "external": { - "external": true, - "fill": "#f6e2c0", - "label": "Library", - "plural": "Libraries", - "stroke": "#b3801f" - }, - "file": { - "fill": "#dbe9f4", - "label": "File", - "plural": "Files", - "stroke": "#4d6f9c" - } - }, - "nodes": [ - { - "external": true, - "id": "ext:once_cell", - "kind": "external", - "name": "once_cell", - "path": "{registry}/once_cell-1.21.4", - "version": "1.21.4" - }, - { - "external": true, - "id": "ext:serde", - "kind": "external", - "name": "serde", - "path": "{registry}/serde-1.0.228", - "version": "1.0.228" - }, - { - "blank": 1, - "bugs": 0.00447, - "cloc": 2, - "crate": "helper", - "cyclomatic": 2, - "effort": 49.128, - "eta1": 5, - "eta2": 2, - "exits": 1, - "fan_in": 1, - "id": "{target}/helper/src/gadget.rs", - "items": 1, - "kind": "file", - "length": 7, - "loc": 6, - "mi": 126.027, - "mi_sei": 145.313, - "n1": 5, - "n2": 2, - "name": "gadget.rs", - "sloc": 3, - "spaces": 2, - "span_sloc": 6, - "time": 2.729, - "visibility": "public", - "vocabulary": 7, - "volume": 19.651 - }, - { - "blank": 2, - "bugs": 0.00376, - "cloc": 7, - "crate": "helper", - "effort": 37.899, - "eta1": 3, - "eta2": 4, - "fan_in": 1, - "id": "{target}/helper/src/lib.rs", - "items": 3, - "kind": "file", - "length": 9, - "loc": 12, - "mi": 113.721, - "mi_sei": 134.757, - "n1": 5, - "n2": 4, - "name": "lib.rs", - "sloc": 3, - "spaces": 1, - "span_sloc": 12, - "time": 2.105, - "visibility": "public", - "vocabulary": 7, - "volume": 25.266 - }, - { - "blank": 2, - "bugs": 0.00447, - "cloc": 2, - "crate": "helper", - "cyclomatic": 2, - "effort": 49.128, - "eta1": 5, - "eta2": 2, - "exits": 1, - "fan_in": 1, - "id": "{target}/helper/src/widget.rs", - "items": 2, - "kind": "file", - "length": 7, - "loc": 8, - "mi": 121.366, - "mi_sei": 134.569, - "n1": 5, - "n2": 2, - "name": "widget.rs", - "sloc": 4, - "spaces": 2, - "span_sloc": 8, - "time": 2.729, - "types": "Widget", - "visibility": "public", - "vocabulary": 7, - "volume": 19.651 - }, - { - "blank": 5, - "bugs": 0.0896, - "cloc": 11, - "crate": "rust-sample", - "cycle": "mutual", - "cyclomatic": 2, - "derives": "Serialize", - "effort": 4413.97, - "eta1": 15, - "eta2": 17, - "exits": 1, - "fan_in": 2, - "fan_out": 2, - "fan_out_external": 1, - "hk": 224, - "id": "{target}/src/a.rs", - "imports": "HashMap::new,helpers::offset", - "items": 2, - "kind": "file", - "length": 69, - "lloc": 1, - "loc": 30, - "mi": 85.054, - "mi_sei": 87.531, - "n1": 40, - "n2": 29, - "name": "a.rs", - "sloc": 14, - "spaces": 2, - "span_sloc": 30, - "time": 245.22, - "types": "Alpha", - "visibility": "public", - "vocabulary": 32, - "volume": 345 - }, - { - "blank": 4, - "bugs": 0.0521, - "cloc": 15, - "closures": 1, - "crate": "rust-sample", - "cycle": "mutual", - "cyclomatic": 4, - "effort": 1958.647, - "eta1": 14, - "eta2": 13, - "exits": 2, - "fan_in": 3, - "fan_out": 1, - "fan_out_external": 1, - "hk": 81, - "id": "{target}/src/b.rs", - "imports": "once_cell::sync::Lazy,once_cell::sync::Lazy::new", - "items": 2, - "kind": "file", - "length": 45, - "lloc": 2, - "loc": 28, - "macros": "println,pull_in_c", - "mi": 88.195, - "mi_sei": 97.249, - "n1": 28, - "n2": 17, - "name": "b.rs", - "sloc": 9, - "spaces": 4, - "span_sloc": 28, - "time": 108.813, - "visibility": "public", - "vocabulary": 27, - "volume": 213.969 - }, - { - "attrs": "test", - "blank": 3, - "bugs": 0.00867, - "cloc": 9, - "crate": "rust-sample", - "cyclomatic": 3, - "effort": 132.877, - "eta1": 5, - "eta2": 5, - "exits": 2, - "fan_in": 2, - "id": "{target}/src/c.rs", - "items": 3, - "kind": "file", - "length": 16, - "loc": 32, - "macros": "assert_eq", - "mi": 101.118, - "mi_sei": 113.588, - "n1": 11, - "n2": 5, - "name": "c.rs", - "sloc": 8, - "spaces": 3, - "span_sloc": 20, - "time": 7.382, - "tloc": 12, - "visibility": "public", - "vocabulary": 10, - "volume": 53.15 - }, - { - "bugs": 0.0011, - "cloc": 5, - "crate": "rust-sample", - "effort": 6, - "eta1": 1, - "eta2": 3, - "id": "{target}/src/chain.rs", - "items": 3, - "kind": "file", - "length": 6, - "loc": 8, - "mi": 124.161, - "mi_sei": 150.564, - "n1": 3, - "n2": 3, - "name": "chain.rs", - "sloc": 3, - "spaces": 1, - "span_sloc": 8, - "time": 0.333, - "visibility": "public", - "vocabulary": 4, - "volume": 12 - }, - { - "blank": 1, - "bugs": 0.011, - "cloc": 1, - "crate": "rust-sample", - "cycle": "chain", - "cyclomatic": 2, - "effort": 190.195, - "eta1": 6, - "eta2": 3, - "exits": 1, - "fan_in": 1, - "fan_out": 1, - "hk": 4, - "id": "{target}/src/chain/one.rs", - "items": 1, - "kind": "file", - "length": 12, - "loc": 6, - "mi": 122.592, - "mi_sei": 130.923, - "n1": 7, - "n2": 5, - "name": "one.rs", - "sloc": 4, - "spaces": 2, - "span_sloc": 6, - "time": 10.566, - "visibility": "public", - "vocabulary": 9, - "volume": 38.039 - }, - { - "blank": 1, - "bugs": 0.011, - "cloc": 1, - "crate": "rust-sample", - "cycle": "chain", - "cyclomatic": 2, - "effort": 190.195, - "eta1": 6, - "eta2": 3, - "exits": 1, - "fan_in": 1, - "fan_out": 1, - "hk": 4, - "id": "{target}/src/chain/three.rs", - "items": 1, - "kind": "file", - "length": 12, - "loc": 6, - "mi": 122.592, - "mi_sei": 130.923, - "n1": 7, - "n2": 5, - "name": "three.rs", - "sloc": 4, - "spaces": 2, - "span_sloc": 6, - "time": 10.566, - "visibility": "public", - "vocabulary": 9, - "volume": 38.039 - }, - { - "blank": 1, - "bugs": 0.011, - "cloc": 1, - "crate": "rust-sample", - "cycle": "chain", - "cyclomatic": 2, - "effort": 190.195, - "eta1": 6, - "eta2": 3, - "exits": 1, - "fan_in": 1, - "fan_out": 1, - "hk": 4, - "id": "{target}/src/chain/two.rs", - "items": 1, - "kind": "file", - "length": 12, - "loc": 6, - "mi": 122.592, - "mi_sei": 130.923, - "n1": 7, - "n2": 5, - "name": "two.rs", - "sloc": 4, - "spaces": 2, - "span_sloc": 6, - "time": 10.566, - "visibility": "public", - "vocabulary": 9, - "volume": 38.039 - }, - { - "args": 6, - "blank": 2, - "branches": 4, - "bugs": 0.158, - "cloc": 17, - "closures": 1, - "cognitive": 5, - "crate": "rust-sample", - "cyclomatic": 8, - "effort": 10375, - "eta1": 20, - "eta2": 12, - "exits": 4, - "id": "{target}/src/complex.rs", - "items": 2, - "kind": "file", - "length": 83, - "lloc": 6, - "loc": 32, - "mi": 81.668, - "mi_sei": 88.138, - "n1": 53, - "n2": 30, - "name": "complex.rs", - "sloc": 15, - "spaces": 4, - "span_sloc": 32, - "time": 576.388, - "unsafe": 1, - "visibility": "public", - "vocabulary": 32, - "volume": 415 - }, - { - "blank": 2, - "bugs": 0.0331, - "cloc": 11, - "crate": "rust-sample", - "cyclomatic": 2, - "effort": 991.183, - "eta1": 10, - "eta2": 9, - "exits": 1, - "fan_out": 3, - "id": "{target}/src/cross.rs", - "items": 1, - "kind": "file", - "length": 30, - "lloc": 1, - "loc": 20, - "mi": 96.801, - "mi_sei": 109.773, - "n1": 16, - "n2": 14, - "name": "cross.rs", - "sloc": 7, - "spaces": 2, - "span_sloc": 20, - "time": 55.065, - "visibility": "public", - "vocabulary": 19, - "volume": 127.437 - }, - { - "blank": 1, - "bugs": 0.00147, - "cloc": 16, - "crate": "rust-sample", - "effort": 9.287, - "eta1": 1, - "eta2": 4, - "id": "{target}/src/cycle_examples.rs", - "items": 4, - "kind": "file", - "length": 8, - "loc": 21, - "mi": 106.255, - "mi_sei": 126.505, - "n1": 4, - "n2": 4, - "name": "cycle_examples.rs", - "sloc": 4, - "spaces": 1, - "span_sloc": 21, - "time": 0.515, - "visibility": "public", - "vocabulary": 5, - "volume": 18.575 - }, - { - "blank": 2, - "bugs": 0.000974, - "cloc": 7, - "crate": "rust-sample", - "effort": 5, - "eta1": 1, - "eta2": 3, - "fan_in": 1, - "id": "{target}/src/cycle_examples/reex_hub.rs", - "items": 1, - "kind": "file", - "length": 5, - "loc": 11, - "mi": 119.95, - "mi_sei": 144.674, - "n1": 2, - "n2": 3, - "name": "reex_hub.rs", - "sloc": 2, - "spaces": 1, - "span_sloc": 11, - "time": 0.277, - "types": "Hub", - "visibility": "public", - "vocabulary": 4, - "volume": 10 - }, - { - "args": 1, - "blank": 3, - "bugs": 0.00736, - "cloc": 4, - "crate": "rust-sample", - "cyclomatic": 2, - "effort": 103.782, - "eta1": 5, - "eta2": 6, - "exits": 1, - "fan_out": 1, - "id": "{target}/src/cycle_examples/reex_spoke.rs", - "items": 2, - "kind": "file", - "length": 12, - "loc": 12, - "mi": 110.909, - "mi_sei": 123.503, - "n1": 6, - "n2": 6, - "name": "reex_spoke.rs", - "sloc": 5, - "spaces": 2, - "span_sloc": 12, - "time": 5.765, - "types": "Widget", - "visibility": "public", - "vocabulary": 11, - "volume": 41.513 - }, - { - "blank": 4, - "bugs": 0.00959, - "cloc": 4, - "crate": "rust-sample", - "cyclomatic": 2, - "effort": 154.533, - "eta1": 5, - "eta2": 4, - "exits": 1, - "fan_out": 1, - "id": "{target}/src/cycle_examples/sup_loose.rs", - "items": 3, - "kind": "file", - "length": 13, - "loc": 14, - "mi": 108.45, - "mi_sei": 117.795, - "n1": 7, - "n2": 6, - "name": "sup_loose.rs", - "sloc": 6, - "spaces": 2, - "span_sloc": 14, - "time": 8.585, - "types": "Bough", - "visibility": "public", - "vocabulary": 9, - "volume": 41.209 - }, - { - "blank": 2, - "cloc": 7, - "crate": "rust-sample", - "eta1": 2, - "fan_in": 1, - "id": "{target}/src/cycle_examples/sup_loose/child.rs", - "items": 1, - "kind": "file", - "length": 3, - "loc": 11, - "mi": 126.211, - "mi_sei": 153.706, - "n1": 3, - "name": "child.rs", - "sloc": 2, - "spaces": 1, - "span_sloc": 11, - "types": "Pip", - "visibility": "public", - "vocabulary": 2, - "volume": 3 - }, - { - "blank": 4, - "bugs": 0.00959, - "cloc": 12, - "crate": "rust-sample", - "cyclomatic": 2, - "effort": 154.533, - "eta1": 5, - "eta2": 4, - "exits": 1, - "fan_out": 1, - "id": "{target}/src/cycle_examples/sup_parent.rs", - "items": 3, - "kind": "file", - "length": 13, - "loc": 22, - "mi": 101.128, - "mi_sei": 115.917, - "n1": 7, - "n2": 6, - "name": "sup_parent.rs", - "sloc": 6, - "spaces": 2, - "span_sloc": 22, - "time": 8.585, - "types": "Nest", - "visibility": "public", - "vocabulary": 9, - "volume": 41.209 - }, - { - "args": 1, - "blank": 3, - "bugs": 0.00488, - "cloc": 9, - "crate": "rust-sample", - "cyclomatic": 2, - "effort": 56.147, - "eta1": 5, - "eta2": 2, - "fan_in": 1, - "id": "{target}/src/cycle_examples/sup_parent/child.rs", - "items": 2, - "kind": "file", - "length": 8, - "loc": 15, - "mi": 110.488, - "mi_sei": 130.506, - "n1": 6, - "n2": 2, - "name": "child.rs", - "sloc": 3, - "spaces": 2, - "span_sloc": 15, - "time": 3.119, - "types": "Chick", - "visibility": "public", - "vocabulary": 7, - "volume": 22.458 - }, - { - "attrs": "test", - "blank": 2, - "bugs": 0.0051, - "cloc": 9, - "crate": "rust-sample", - "derives": "Serialize", - "effort": 60, - "eta1": 5, - "eta2": 3, - "fan_out_external": 1, - "id": "{target}/src/derives.rs", - "imports": "crate::derives::OnlyDerived,serde::Serialize", - "items": 2, - "kind": "file", - "length": 8, - "loc": 23, - "macros": "assert_eq", - "mi": 110.373, - "mi_sei": 130.238, - "n1": 5, - "n2": 3, - "name": "derives.rs", - "sloc": 4, - "spaces": 1, - "span_sloc": 15, - "time": 3.333, - "tloc": 8, - "types": "OnlyDerived", - "visibility": "public", - "vocabulary": 8, - "volume": 24 - }, - { - "blank": 3, - "bugs": 0.0123, - "cloc": 12, - "crate": "rust-sample", - "cyclomatic": 2, - "effort": 225.852, - "eta1": 7, - "eta2": 5, - "exits": 1, - "fan_in": 1, - "fan_out": 1, - "hk": 5, - "id": "{target}/src/foo.rs", - "items": 2, - "kind": "file", - "length": 15, - "loc": 20, - "mi": 101.288, - "mi_sei": 117.232, - "n1": 9, - "n2": 6, - "name": "foo.rs", - "sloc": 5, - "spaces": 2, - "span_sloc": 20, - "time": 12.547, - "visibility": "private", - "vocabulary": 12, - "volume": 53.774 - }, - { - "blank": 1, - "bugs": 0.01, - "cloc": 14, - "crate": "rust-sample", - "cyclomatic": 2, - "effort": 166.052, - "eta1": 8, - "eta2": 3, - "exits": 1, - "id": "{target}/src/foo/bar.rs", - "items": 1, - "kind": "file", - "length": 12, - "loc": 19, - "mi": 103.464, - "mi_sei": 122.326, - "n1": 9, - "n2": 3, - "name": "bar.rs", - "sloc": 4, - "spaces": 2, - "span_sloc": 19, - "time": 9.225, - "visibility": "public", - "vocabulary": 11, - "volume": 41.513 - }, - { - "attrs": "macro_use,path,test", - "blank": 7, - "bugs": 0.0355, - "cloc": 34, - "crate": "rust-sample", - "cyclomatic": 2, - "effort": 1099.872, - "eta1": 9, - "eta2": 19, - "exits": 1, - "fan_out": 3, - "id": "{target}/src/lib.rs", - "imports": "foo::run", - "items": 14, - "kind": "file", - "length": 46, - "lloc": 1, - "loc": 71, - "macros": "assert_eq,make_answer", - "mi": 76.137, - "mi_sei": 80.309, - "n1": 25, - "n2": 21, - "name": "lib.rs", - "sloc": 19, - "spaces": 2, - "span_sloc": 60, - "time": 61.104, - "tloc": 11, - "visibility": "public", - "vocabulary": 28, - "volume": 221.138 - }, - { - "blank": 2, - "bugs": 0.0174, - "cloc": 7, - "crate": "rust-sample", - "effort": 377.753, - "eta1": 7, - "eta2": 6, - "id": "{target}/src/macros.rs", - "items": 2, - "kind": "file", - "length": 25, - "loc": 22, - "macros": "macro_rules", - "mi": 97.153, - "mi_sei": 102.903, - "n1": 18, - "n2": 7, - "name": "macros.rs", - "sloc": 13, - "spaces": 1, - "span_sloc": 22, - "time": 20.986, - "visibility": "private", - "vocabulary": 13, - "volume": 92.51 - }, - { - "blank": 2, - "bugs": 0.0112, - "cloc": 5, - "crate": "rust-sample", - "cyclomatic": 2, - "effort": 196.755, - "eta1": 7, - "eta2": 4, - "exits": 1, - "fan_out": 1, - "id": "{target}/src/relocated/custom.rs", - "items": 1, - "kind": "file", - "length": 13, - "loc": 11, - "mi": 111.902, - "mi_sei": 129.176, - "n1": 8, - "n2": 5, - "name": "custom.rs", - "sloc": 4, - "spaces": 2, - "span_sloc": 11, - "time": 10.93, - "visibility": "private", - "vocabulary": 11, - "volume": 44.972 + "principles": [ + { + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/rust/CPX.md", + "id": "CPX", + "label": "CPX", + "prompt": "These modules are too complex and I want to reduce their complexity.\nReduce it by splitting large units into smaller single-responsibility ones,\nextracting repeated patterns into shared helpers, flattening deeply nested\ncontrol flow, and breaking large functions into focused helpers.", + "sort_metric": "cognitive", + "title": "CPX — Reduce Complexity" + }, + { + "connections": [ + "common" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/rust/ADP.md", + "id": "ADP", + "label": "ADP", + "prompt": "The dependency graph between modules must form a DAG. When module A depends\non module B, no chain of dependencies should bring B back to A.\n\nIdentify any cycles in the modules below. For each cycle, propose a concrete\nrefactoring (extract a shared abstraction, invert a dependency, split a module)\nthat makes the graph acyclic without breaking existing functionality.\n\nWhen splitting a module to break a cycle, the new structure should:\n- Preserve existing API contracts\n- Minimise coupling in the new structure\n- Follow the Single Responsibility Principle\n- Not introduce new dependency cycles", + "sort_metric": "cycle", + "title": "ADP — Acyclic Dependencies Principle" + }, + { + "connections": [ + "in", + "out" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/rust/SRP.md", + "id": "SRP", + "label": "SRP", + "prompt": "A module should have one reason to change — it should serve one actor\nand encapsulate one coherent set of decisions.\n\nFor each module below, identify whether it has more than one responsibility.\nPropose how to split responsibilities so each module changes for only one reason,\nand specify the new module boundaries.", + "sort_metric": "sloc", + "title": "SRP — Single Responsibility Principle" + }, + { + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/rust/OCP.md", + "id": "OCP", + "label": "OCP", + "prompt": "A module should be open for extension but closed for modification: new behaviour\nshould be addable without editing existing, working code.\n\nFor each module below, identify extension points that currently require editing\nexisting code (e.g. growing match/switch/if-else chains). Propose an extension\nmechanism (polymorphism, strategy, plug-in registration) so new cases can be added\nwithout modifying these modules.", + "sort_metric": "cyclomatic", + "title": "OCP — Open/Closed Principle" + }, + { + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/rust/LSP.md", + "id": "LSP", + "label": "LSP", + "prompt": "Every implementation of an interface must honour its full contract — return-value\ninvariants, error/exception behaviour, side effects, and resource ownership — not\njust the method signatures. A subtype must be substitutable for its base without\nsurprising callers.\n\nIdentify the interface implementations in the modules below. For each, check it can\nreplace any other implementation of the same interface without breaking callers.\nFlag violations and propose fixes.", + "sort_metric": "hk", + "title": "LSP — Liskov Substitution Principle" + }, + { + "connections": [ + "in" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/rust/ISP.md", + "id": "ISP", + "label": "ISP", + "prompt": "Clients should not be forced to depend on methods they do not use. Prefer several\nsmall, focused interfaces over one wide interface.\n\nIdentify interfaces in the modules below that are wider than their consumers need.\nPropose how to split them into narrower interfaces so each consumer depends only on\nwhat it actually uses.", + "sort_metric": "items", + "title": "ISP — Interface Segregation Principle" + }, + { + "connections": [ + "common", + "out" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/rust/DIP.md", + "id": "DIP", + "label": "DIP", + "prompt": "High-level modules should not depend on low-level modules; both should depend on\nabstractions, and abstractions should not depend on details.\n\nFind places in the modules below where a high-level module depends directly on a\nconcrete low-level type. Propose an abstraction (interface) to invert each such\ndependency, and specify where the concrete implementation should be wired in.", + "sort_metric": "fan_out", + "title": "DIP — Dependency Inversion Principle" + }, + { + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/rust/DRY.md", + "id": "DRY", + "label": "DRY", + "prompt": "Every piece of knowledge must have a single authoritative representation.\nDRY is about knowledge duplication, not just code duplication.\n\nIdentify concepts, rules, or policies that are duplicated across the modules\nbelow. For each duplication, propose a canonical location and the refactoring\nneeded to consolidate it.", + "sort_metric": "sloc", + "title": "DRY — Don't Repeat Yourself" + }, + { + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/rust/KISS.md", + "id": "KISS", + "label": "KISS", + "prompt": "When two designs solve the same problem, prefer the simpler one — fewer\nabstractions, fewer indirection layers, fewer moving parts.\n\nIdentify over-engineered or needlessly complex constructs in the modules below.\nFor each, describe the simpler alternative and estimate the risk of simplifying.", + "sort_metric": "cognitive", + "title": "KISS — Keep It Simple" + }, + { + "connections": [ + "common", + "out" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/rust/LoD.md", + "id": "LoD", + "label": "LoD", + "prompt": "A method should only call methods on: itself, its direct fields,\nits parameters, and objects it constructs locally.\nAvoid `x.foo().bar().baz()` chains that traverse object graphs.\n\nIdentify method chains or deep field traversals in the modules below that\nviolate LoD. For each, propose a narrow accessor or a facade that exposes only\nwhat the caller needs, reducing coupling.", + "sort_metric": "fan_out", + "title": "Law of Demeter — Principle of Least Knowledge" + }, + { + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/rust/MISU.md", + "id": "MISU", + "label": "MISU", + "prompt": "Move correctness from runtime checks into the type system, so invalid states\ncannot be constructed and fail at compile time rather than at runtime.\n\nIdentify data structures or function signatures in the modules below where invalid\nstates are representable at runtime. For each, propose a type-level encoding\n(sum type / enum, newtype, typestate) that makes the invalid state unrepresentable\nby construction.", + "sort_metric": "cyclomatic", + "title": "MISU — Make Invalid States Unrepresentable" + }, + { + "connections": [ + "common" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/rust/CoI.md", + "id": "CoI", + "label": "CoI", + "prompt": "Build behaviour by composing small, focused pieces rather than through deep\ninheritance hierarchies.\n\nIdentify large types that accumulate behaviour in the modules below. Propose how to\ndecompose them into smaller composable parts, and show how consumers would assemble\nthe behaviour they need.", + "sort_metric": "items", + "title": "CoI — Composition Over Inheritance" + }, + { + "connections": [ + "out" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/rust/YAGNI.md", + "id": "YAGNI", + "label": "YAGNI", + "prompt": "Build for the problem you have now, not one you imagine you might have later.\nDon't add an abstraction, a generic parameter, or a public API for a hypothetical\nfuture use.\n\nIdentify abstractions, generics, or public APIs in the modules below that were\nadded speculatively. For each, assess whether multiple real callers use it today,\nand propose simplification if not.", + "sort_metric": "sloc", + "title": "YAGNI — You Aren't Gonna Need It" } ], - "stats": { - "blank": 2.5, - "bugs": 0.0214, - "cloc": 8.88, - "cognitive": 5, - "cyclomatic": 2.5, - "effort": 883.082, - "fan_in": 1.307, - "fan_out": 1.416, - "hk": 53.666, - "length": 19.56, - "mi": 107.583, - "mi_sei": 121.961, - "sloc": 6.2, - "time": 49.059, - "tloc": 10.333, - "unsafe": 1, - "vocabulary": 12.08, - "volume": 80.044 - }, - "ui": { - "card": [ - "hk", - "sloc" - ], - "columns": [ - "kind", - "cycle", - "sloc", - "hk", - "fan_in", - "fan_out", - "bugs", - "cyclomatic", - "cognitive", - "mi", - "mi_sei", - "lloc", - "cloc", - "blank", - "tloc", - "unsafe" - ], - "default_sort": "cycle", - "filter": [ - "cycle" - ], - "grouping": { - "key": "crate" - }, - "size": [ - "sloc", - "hk" - ], - "sort": [ - "cycle", - "sloc", - "hk", - "fan_in", - "fan_out", - "bugs", - "cyclomatic", - "cognitive", - "mi", - "mi_sei", - "lloc", - "cloc", - "blank", - "tloc", - "unsafe" - ], - "summary": [ - "sloc", - "hk", - "fan_in", - "fan_out", - "bugs", - "cyclomatic", - "cognitive", - "mi", - "mi_sei", - "lloc", - "cloc", - "blank", - "tloc", - "unsafe" + "prompt": { + "cycle_note": "This is **one** dependency cycle; every module in it is listed below so the whole loop is visible. Fix one cycle at a time — `--top 2`+ lists several separate cycles at once and obscures how each one connects.", + "doc_note": "**First, before reading the source**, run `code-ranker docs {id}` — it prints the full principle and, for your language, the usual cause of this exact violation and the smallest correct fix, often with a worked example and how to confirm it. Read it first: it normally names the remedy outright, so you apply it instead of re-deriving the mechanism from the code.", + "focus": "**Focus the research and report primarily on the modules below.**", + "intro": "I want to apply this to some modules in my system.", + "task": [ + "- Prepare a precise, detailed estimate and a report of where the modules below violate it.", + "- If you find more serious violations elsewhere during research, mention them in the report too.", + "- Show a summary of the report in chat.", + "- If any violation is found, suggest saving the report to a file as a plan for a detailed review, named `.code-ranker/-{id}.md`." ] } } }, - "plugin": "rust", - "principles": [ - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/rust/CPX.md", - "id": "CPX", - "label": "CPX", - "prompt": "These modules are too complex and I want to reduce their complexity.\nReduce it by splitting large units into smaller single-responsibility ones,\nextracting repeated patterns into shared helpers, flattening deeply nested\ncontrol flow, and breaking large functions into focused helpers.", - "sort_metric": "cognitive", - "title": "CPX — Reduce Complexity" - }, - { - "connections": [ - "common" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/rust/ADP.md", - "id": "ADP", - "label": "ADP", - "prompt": "The dependency graph between modules must form a DAG. When module A depends\non module B, no chain of dependencies should bring B back to A.\n\nIdentify any cycles in the modules below. For each cycle, propose a concrete\nrefactoring (extract a shared abstraction, invert a dependency, split a module)\nthat makes the graph acyclic without breaking existing functionality.\n\nWhen splitting a module to break a cycle, the new structure should:\n- Preserve existing API contracts\n- Minimise coupling in the new structure\n- Follow the Single Responsibility Principle\n- Not introduce new dependency cycles", - "sort_metric": "cycle", - "title": "ADP — Acyclic Dependencies Principle" - }, - { - "connections": [ - "in", - "out" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/rust/SRP.md", - "id": "SRP", - "label": "SRP", - "prompt": "A module should have one reason to change — it should serve one actor\nand encapsulate one coherent set of decisions.\n\nFor each module below, identify whether it has more than one responsibility.\nPropose how to split responsibilities so each module changes for only one reason,\nand specify the new module boundaries.", - "sort_metric": "sloc", - "title": "SRP — Single Responsibility Principle" - }, - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/rust/OCP.md", - "id": "OCP", - "label": "OCP", - "prompt": "A module should be open for extension but closed for modification: new behaviour\nshould be addable without editing existing, working code.\n\nFor each module below, identify extension points that currently require editing\nexisting code (e.g. growing match/switch/if-else chains). Propose an extension\nmechanism (polymorphism, strategy, plug-in registration) so new cases can be added\nwithout modifying these modules.", - "sort_metric": "cyclomatic", - "title": "OCP — Open/Closed Principle" - }, - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/rust/LSP.md", - "id": "LSP", - "label": "LSP", - "prompt": "Every implementation of an interface must honour its full contract — return-value\ninvariants, error/exception behaviour, side effects, and resource ownership — not\njust the method signatures. A subtype must be substitutable for its base without\nsurprising callers.\n\nIdentify the interface implementations in the modules below. For each, check it can\nreplace any other implementation of the same interface without breaking callers.\nFlag violations and propose fixes.", - "sort_metric": "hk", - "title": "LSP — Liskov Substitution Principle" - }, - { - "connections": [ - "in" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/rust/ISP.md", - "id": "ISP", - "label": "ISP", - "prompt": "Clients should not be forced to depend on methods they do not use. Prefer several\nsmall, focused interfaces over one wide interface.\n\nIdentify interfaces in the modules below that are wider than their consumers need.\nPropose how to split them into narrower interfaces so each consumer depends only on\nwhat it actually uses.", - "sort_metric": "items", - "title": "ISP — Interface Segregation Principle" - }, - { - "connections": [ - "common", - "out" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/rust/DIP.md", - "id": "DIP", - "label": "DIP", - "prompt": "High-level modules should not depend on low-level modules; both should depend on\nabstractions, and abstractions should not depend on details.\n\nFind places in the modules below where a high-level module depends directly on a\nconcrete low-level type. Propose an abstraction (interface) to invert each such\ndependency, and specify where the concrete implementation should be wired in.", - "sort_metric": "fan_out", - "title": "DIP — Dependency Inversion Principle" - }, - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/rust/DRY.md", - "id": "DRY", - "label": "DRY", - "prompt": "Every piece of knowledge must have a single authoritative representation.\nDRY is about knowledge duplication, not just code duplication.\n\nIdentify concepts, rules, or policies that are duplicated across the modules\nbelow. For each duplication, propose a canonical location and the refactoring\nneeded to consolidate it.", - "sort_metric": "sloc", - "title": "DRY — Don't Repeat Yourself" - }, - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/rust/KISS.md", - "id": "KISS", - "label": "KISS", - "prompt": "When two designs solve the same problem, prefer the simpler one — fewer\nabstractions, fewer indirection layers, fewer moving parts.\n\nIdentify over-engineered or needlessly complex constructs in the modules below.\nFor each, describe the simpler alternative and estimate the risk of simplifying.", - "sort_metric": "cognitive", - "title": "KISS — Keep It Simple" - }, - { - "connections": [ - "common", - "out" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/rust/LoD.md", - "id": "LoD", - "label": "LoD", - "prompt": "A method should only call methods on: itself, its direct fields,\nits parameters, and objects it constructs locally.\nAvoid `x.foo().bar().baz()` chains that traverse object graphs.\n\nIdentify method chains or deep field traversals in the modules below that\nviolate LoD. For each, propose a narrow accessor or a facade that exposes only\nwhat the caller needs, reducing coupling.", - "sort_metric": "fan_out", - "title": "Law of Demeter — Principle of Least Knowledge" - }, - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/rust/MISU.md", - "id": "MISU", - "label": "MISU", - "prompt": "Move correctness from runtime checks into the type system, so invalid states\ncannot be constructed and fail at compile time rather than at runtime.\n\nIdentify data structures or function signatures in the modules below where invalid\nstates are representable at runtime. For each, propose a type-level encoding\n(sum type / enum, newtype, typestate) that makes the invalid state unrepresentable\nby construction.", - "sort_metric": "cyclomatic", - "title": "MISU — Make Invalid States Unrepresentable" - }, - { - "connections": [ - "common" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/rust/CoI.md", - "id": "CoI", - "label": "CoI", - "prompt": "Build behaviour by composing small, focused pieces rather than through deep\ninheritance hierarchies.\n\nIdentify large types that accumulate behaviour in the modules below. Propose how to\ndecompose them into smaller composable parts, and show how consumers would assemble\nthe behaviour they need.", - "sort_metric": "items", - "title": "CoI — Composition Over Inheritance" - }, - { - "connections": [ - "out" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/rust/YAGNI.md", - "id": "YAGNI", - "label": "YAGNI", - "prompt": "Build for the problem you have now, not one you imagine you might have later.\nDon't add an abstraction, a generic parameter, or a public API for a hypothetical\nfuture use.\n\nIdentify abstractions, generics, or public APIs in the modules below that were\nadded speculatively. For each, assess whether multiple real callers use it today,\nand propose simplification if not.", - "sort_metric": "sloc", - "title": "YAGNI — You Aren't Gonna Need It" - } + "plugins": [ + "rust" ], - "prompt": { - "cycle_note": "This is **one** dependency cycle; every module in it is listed below so the whole loop is visible. Fix one cycle at a time — `--top 2`+ lists several separate cycles at once and obscures how each one connects.", - "doc_note": "**First, before reading the source**, run `code-ranker docs {id}` — it prints the full principle and, for your language, the usual cause of this exact violation and the smallest correct fix, often with a worked example and how to confirm it. Read it first: it normally names the remedy outright, so you apply it instead of re-deriving the mechanism from the code.", - "focus": "**Focus the research and report primarily on the modules below.**", - "intro": "I want to apply this to some modules in my system.", - "task": [ - "- Prepare a precise, detailed estimate and a report of where the modules below violate it.", - "- If you find more serious violations elsewhere during research, mention them in the report too.", - "- Show a summary of the report in chat.", - "- If any violation is found, suggest saving the report to a file as a plan for a detailed review, named `.code-ranker/-{id}.md`." - ] - }, "roots": { "registry": "/home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f", "target": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/rust/tests/sample" }, - "schema_version": "4.0", + "schema_version": "5.0", "target": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/rust/tests/sample", "timings": [ { "detail": "27 nodes from 25 files", "ms": 0, - "stage": "rust" + "stage": "rust: parse" }, { "detail": "25 nodes annotated", "ms": 0, - "stage": "complexity" + "stage": "rust: complexity" }, { "detail": "nodes=27 edges=48", "ms": 0, - "stage": "projection" + "stage": "rust: projection" } ], "versions": { - "code-ranker": "4.0.0", + "code-ranker": "5.0.0", "rustc": "1.96.0" }, "workspace": "/home/user/code-ranker" diff --git a/crates/code-ranker-plugins/src/languages/rust/tests/sample/code-ranker.toml b/crates/code-ranker-plugins/src/languages/rust/tests/sample/code-ranker.toml index c664bdf2..5d0877c3 100644 --- a/crates/code-ranker-plugins/src/languages/rust/tests/sample/code-ranker.toml +++ b/crates/code-ranker-plugins/src/languages/rust/tests/sample/code-ranker.toml @@ -1,9 +1,9 @@ -version = "4.0" +version = "5.0" # Self-contained config for the code-ranker "rust" sample fixture. # Pin the plugin and KEEP test files in the graph (ignore.tests = false) so the # fixture is reproducible regardless of any repo-level config and so that test # files' imports stay visible in the report. -plugin = "rust" - -[ignore] +[plugins] +enabled = ["rust"] +[plugins.base.ignore] tests = false diff --git a/crates/code-ranker-plugins/src/languages/typescript/config.toml b/crates/code-ranker-plugins/src/languages/ts/config.toml similarity index 94% rename from crates/code-ranker-plugins/src/languages/typescript/config.toml rename to crates/code-ranker-plugins/src/languages/ts/config.toml index 26086dc8..fe382ca1 100644 --- a/crates/code-ranker-plugins/src/languages/typescript/config.toml +++ b/crates/code-ranker-plugins/src/languages/ts/config.toml @@ -11,7 +11,7 @@ # Principle-corpus language for doc links (`doc_base` inherited from defaults). # TypeScript ships a full own corpus, so `doc_overrides = "*"` routes every # principle to `typescript/` instead of the shared `base/` fallback. -doc_lang = "typescript" +doc_lang = "ts" doc_overrides = "*" # ────────────────────────────────────────────────────────────────────────────── @@ -27,3 +27,5 @@ doc_overrides = "*" extensions = ["ts", "tsx", "mts", "cts"] resolution_order = ["ts", "tsx", "mts", "cts", "js", "jsx"] detect_markers = ["tsconfig.json"] +# Short aliases accepted anywhere a language is named. Unique across languages. +aliases = ["typescript"] diff --git a/crates/code-ranker-plugins/src/languages/typescript/mod.rs b/crates/code-ranker-plugins/src/languages/ts/mod.rs similarity index 78% rename from crates/code-ranker-plugins/src/languages/typescript/mod.rs rename to crates/code-ranker-plugins/src/languages/ts/mod.rs index ba5fa522..cf16d06c 100644 --- a/crates/code-ranker-plugins/src/languages/typescript/mod.rs +++ b/crates/code-ranker-plugins/src/languages/ts/mod.rs @@ -36,44 +36,44 @@ static CONFIG: LazyLock = LazyLock::new(|| { // Self-register this plugin (collected by `code_ranker_plugin_api::registry`); no // central list anywhere names a language. inventory::submit! { - code_ranker_plugin_api::PluginRegistration(&TypescriptPlugin) + code_ranker_plugin_api::PluginRegistration(&TsPlugin) } /// The TypeScript language plugin (handles .ts / .tsx / .mts / .cts). -pub struct TypescriptPlugin; +pub struct TsPlugin; -impl LanguagePlugin for TypescriptPlugin { +impl LanguagePlugin for TsPlugin { fn config(&self) -> toml::Table { CONFIG.clone() } fn name(&self) -> &str { - "typescript" + "ts" } - fn detect(&self, workspace: &Path, _input: &PluginInput) -> bool { + fn detect(&self, cfg: &toml::Table, workspace: &Path, _input: &PluginInput) -> bool { // Project-detect marker filenames are DATA: read from `config.toml`'s // `detect_markers` (the detect logic stays in Rust). TS detects on // `tsconfig.json`. - crate::config::string_list(&CONFIG, "detect_markers") + crate::config::string_list(cfg, "detect_markers") .iter() .any(|m| detect_with_marker(workspace, m)) } - fn levels(&self) -> Vec { + fn levels(&self, cfg: &toml::Table) -> Vec { vec![ - ecmascript_level("files", &CONFIG), - ecmascript_functions_level(&CONFIG), + ecmascript_level("files", cfg), + ecmascript_functions_level(cfg), ] } - fn analyze(&self, workspace: &Path, input: &PluginInput) -> Result { + fn analyze(&self, cfg: &toml::Table, workspace: &Path, input: &PluginInput) -> Result { // File-collection extensions and the TS-first import-resolution order are // DATA: read from `config.toml`'s `extensions` / `resolution_order`. The // grammar selector ([`grammar_for`]) stays in Rust (string → grammar TYPE). - let exts = crate::config::string_list(&CONFIG, "extensions"); + let exts = crate::config::string_list(cfg, "extensions"); let exts: Vec<&str> = exts.iter().map(String::as_str).collect(); - let order = crate::config::string_list(&CONFIG, "resolution_order"); + let order = crate::config::string_list(cfg, "resolution_order"); let order: Vec<&str> = order.iter().map(String::as_str).collect(); analyze_ecmascript( workspace, @@ -85,31 +85,35 @@ impl LanguagePlugin for TypescriptPlugin { ) } - fn metrics(&self, graph: &Graph) -> Vec<(String, MetricInputs)> { + fn metrics(&self, _cfg: &toml::Table, graph: &Graph) -> Vec<(String, MetricInputs)> { ecmascript_metrics(graph, grammar_for) } - fn function_units(&self, graph: &Graph) -> Vec<(Node, MetricInputs)> { + fn function_units(&self, _cfg: &toml::Table, graph: &Graph) -> Vec<(Node, MetricInputs)> { ecmascript_function_units(graph, grammar_for) } - fn principles(&self, _input: &PluginInput) -> Vec { + fn principles(&self, cfg: &toml::Table, _input: &PluginInput) -> Vec { // The common catalog from `defaults.toml`, with `doc_url` resolved to // `{doc_base}/typescript/.md` (TypeScript adds no principles of its own). - crate::config::resolved_principles(&CONFIG) + crate::config::resolved_principles(cfg) } - fn report_overrides(&self) -> code_ranker_plugin_api::report::ReportOverride { - code_ranker_plugin_api::list_override::report_override(&CONFIG) + fn report_overrides( + &self, + cfg: &toml::Table, + ) -> code_ranker_plugin_api::report::ReportOverride { + code_ranker_plugin_api::list_override::report_override(cfg) } fn metric_specs( &self, + cfg: &toml::Table, defaults: BTreeMap, ) -> BTreeMap { // Shared ECMAScript Halstead operator/operand descriptions (JS and TS use // the same `[halstead]` vocab → one home in `ecmascript/config.toml`). - ecmascript_metric_specs(defaults) + ecmascript_metric_specs(defaults, cfg) } } diff --git a/crates/code-ranker-plugins/src/languages/typescript/tests/mod_rs.rs b/crates/code-ranker-plugins/src/languages/ts/tests/mod_rs.rs similarity index 89% rename from crates/code-ranker-plugins/src/languages/typescript/tests/mod_rs.rs rename to crates/code-ranker-plugins/src/languages/ts/tests/mod_rs.rs index 7db557cf..6d1f2e17 100644 --- a/crates/code-ranker-plugins/src/languages/typescript/tests/mod_rs.rs +++ b/crates/code-ranker-plugins/src/languages/ts/tests/mod_rs.rs @@ -6,21 +6,23 @@ use tempfile::TempDir; #[test] fn plugin_name_is_typescript() { - assert_eq!(TypescriptPlugin.name(), "typescript"); + assert_eq!(TsPlugin.name(), "ts"); } #[test] fn detect_requires_tsconfig() { let tmp = TempDir::new().unwrap(); let input = PluginInput::default(); - assert!(!TypescriptPlugin.detect(tmp.path(), &input)); + let cfg = TsPlugin.config(); + assert!(!TsPlugin.detect(&cfg, tmp.path(), &input)); fs::write(tmp.path().join("tsconfig.json"), "{}").unwrap(); - assert!(TypescriptPlugin.detect(tmp.path(), &input)); + assert!(TsPlugin.detect(&cfg, tmp.path(), &input)); } #[test] fn levels_returns_files_and_functions() { - let levels = TypescriptPlugin.levels(); + let cfg = TsPlugin.config(); + let levels = TsPlugin.levels(&cfg); assert_eq!(levels.len(), 2); assert_eq!(levels[0].name, "files"); assert!(levels[0].edge_kinds.contains_key("uses")); @@ -51,8 +53,9 @@ fn function_units_extracts_per_function_nodes() { nodes: vec![node(&ts, "a.ts"), node(&tsx, "w.tsx")], edges: vec![], }; - let units: Vec<_> = TypescriptPlugin - .function_units(&graph) + let cfg = TsPlugin.config(); + let units: Vec<_> = TsPlugin + .function_units(&cfg, &graph) .into_iter() .map(|(n, _)| n) .collect(); @@ -88,7 +91,8 @@ fn metrics_measures_ts_and_tsx_file_nodes() { nodes: vec![node(&ts, "a.ts"), node(&tsx, "w.tsx")], edges: vec![], }; - let inputs = TypescriptPlugin.metrics(&graph); + let cfg = TsPlugin.config(); + let inputs = TsPlugin.metrics(&cfg, &graph); // Both the `.ts` and `.tsx` arms map to a grammar, so both files are measured. assert_eq!(inputs.len(), 2, "both ts and tsx files measured"); // The orchestrator writes; mirror it to confirm the `.ts` file has complexity. @@ -115,8 +119,9 @@ fn metrics_skip_unreadable_and_unsupported_files() { nodes: vec![n("/no/such/missing.ts"), n("/x/readme.txt")], edges: vec![], }; - assert!(TypescriptPlugin.metrics(&graph).is_empty()); - assert!(TypescriptPlugin.function_units(&graph).is_empty()); + let cfg = TsPlugin.config(); + assert!(TsPlugin.metrics(&cfg, &graph).is_empty()); + assert!(TsPlugin.function_units(&cfg, &graph).is_empty()); } #[test] @@ -138,9 +143,10 @@ fn analyze_builds_ts_graph_with_imports_and_externals() { ); let input = PluginInput::default(); - let graph = TypescriptPlugin - .analyze(root, &input) - .expect("TypescriptPlugin.analyze should succeed"); + let cfg = TsPlugin.config(); + let graph = TsPlugin + .analyze(&cfg, root, &input) + .expect("TsPlugin.analyze should succeed"); let a_id = root.join("src/a.ts").to_string_lossy().into_owned(); let b_id = root.join("src/b.ts").to_string_lossy().into_owned(); @@ -189,9 +195,10 @@ fn import_path_in_comment_or_string_is_not_an_edge() { ); let input = PluginInput::default(); - let graph = TypescriptPlugin - .analyze(root, &input) - .expect("TypescriptPlugin.analyze should succeed"); + let cfg = TsPlugin.config(); + let graph = TsPlugin + .analyze(&cfg, root, &input) + .expect("TsPlugin.analyze should succeed"); let a_id = root.join("src/a.ts").to_string_lossy().into_owned(); let b_id = root.join("src/b.ts").to_string_lossy().into_owned(); @@ -211,8 +218,9 @@ fn uses_edges_from_a(a_rel: &str, a_src: &str) -> usize { let root = tmp.path(); write_file(root, a_rel, a_src); write_file(root, "src/b.ts", "export const g: number = 1;\n"); - let g = TypescriptPlugin - .analyze(root, &PluginInput::default()) + let cfg = TsPlugin.config(); + let g = TsPlugin + .analyze(&cfg, root, &PluginInput::default()) .expect("analyze should succeed"); let a_id = root.join(a_rel).to_string_lossy().into_owned(); edge_count_from(&g, &a_id, "uses") @@ -316,9 +324,10 @@ fn edges_scale_with_real_imports() { a.push_str("export const y = 1;\n"); write_file(root, "src/a.ts", &a); - let graph = TypescriptPlugin - .analyze(root, &PluginInput::default()) - .expect("TypescriptPlugin.analyze should succeed"); + let cfg = TsPlugin.config(); + let graph = TsPlugin + .analyze(&cfg, root, &PluginInput::default()) + .expect("TsPlugin.analyze should succeed"); let a_id = root.join("src/a.ts").to_string_lossy().into_owned(); let got = graph diff --git a/crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker-check.codequality.json b/crates/code-ranker-plugins/src/languages/ts/tests/sample/code-ranker-check.codequality.json similarity index 83% rename from crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker-check.codequality.json rename to crates/code-ranker-plugins/src/languages/ts/tests/sample/code-ranker-check.codequality.json index 1dc80b28..b74316be 100644 --- a/crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker-check.codequality.json +++ b/crates/code-ranker-plugins/src/languages/ts/tests/sample/code-ranker-check.codequality.json @@ -2,7 +2,7 @@ { "check_name": "cycle.chain", "description": "{target}/src/chain1.ts: chain cycle: {target}/src/chain1.ts → {target}/src/chain3.ts → {target}/src/chain2.ts → (back to start)", - "fingerprint": "cycle.chain:{target}/src/chain1.ts", + "fingerprint": "ts:cycle.chain:{target}/src/chain1.ts", "location": { "lines": { "begin": 2 @@ -14,7 +14,7 @@ { "check_name": "cycle.mutual", "description": "{target}/src/a.ts: mutual cycle between {target}/src/a.ts ↔ {target}/src/b.ts", - "fingerprint": "cycle.mutual:{target}/src/a.ts", + "fingerprint": "ts:cycle.mutual:{target}/src/a.ts", "location": { "lines": { "begin": 6 diff --git a/crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker-check.sarif b/crates/code-ranker-plugins/src/languages/ts/tests/sample/code-ranker-check.sarif similarity index 93% rename from crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker-check.sarif rename to crates/code-ranker-plugins/src/languages/ts/tests/sample/code-ranker-check.sarif index 6eb3a449..f2682460 100644 --- a/crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker-check.sarif +++ b/crates/code-ranker-plugins/src/languages/ts/tests/sample/code-ranker-check.sarif @@ -21,7 +21,7 @@ "text": "{target}/src/chain1.ts: chain cycle: {target}/src/chain1.ts → {target}/src/chain3.ts → {target}/src/chain2.ts → (back to start)" }, "partialFingerprints": { - "codeRankerRuleLocation/v1": "cycle.chain:{target}/src/chain1.ts" + "codeRankerRuleLocation/v1": "ts:cycle.chain:{target}/src/chain1.ts" }, "properties": { "graph": "files", @@ -48,7 +48,7 @@ "text": "{target}/src/a.ts: mutual cycle between {target}/src/a.ts ↔ {target}/src/b.ts" }, "partialFingerprints": { - "codeRankerRuleLocation/v1": "cycle.mutual:{target}/src/a.ts" + "codeRankerRuleLocation/v1": "ts:cycle.mutual:{target}/src/a.ts" }, "properties": { "graph": "files", @@ -90,7 +90,7 @@ } } ], - "version": "3.0.0-alpha.1" + "version": "5.0.0" } } } diff --git a/crates/code-ranker-plugins/src/languages/ts/tests/sample/code-ranker-report.json b/crates/code-ranker-plugins/src/languages/ts/tests/sample/code-ranker-report.json new file mode 100644 index 00000000..ebb5989c --- /dev/null +++ b/crates/code-ranker-plugins/src/languages/ts/tests/sample/code-ranker-report.json @@ -0,0 +1,1035 @@ +{ + "command": "code-ranker report /home/user/code-ranker/crates/code-ranker-plugins/src/languages/ts/tests/sample --config /home/user/code-ranker/crates/code-ranker-plugins/src/languages/ts/tests/sample/code-ranker.toml --output.json.path=/home/user/code-ranker/crates/code-ranker-plugins/src/languages/ts/tests/sample/code-ranker-report.json", + "config_file": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/ts/tests/sample/code-ranker.toml", + "generated_at": "1970-01-01T00:00:00Z", + "git": { + "branch": "main", + "commit": "000000000000", + "dirty_files": 0, + "origin": "git@example.com:org/repo.git" + }, + "languages": { + "ts": { + "graphs": { + "files": { + "attribute_groups": { + "complexity": { + "description": "per-function branching, nesting & size", + "label": "Complexity" + }, + "coupling": { + "description": "how tightly modules depend on each other", + "label": "Coupling" + }, + "halstead": { + "description": "operator/operand vocabulary & derived effort", + "label": "Halstead" + }, + "loc": { + "description": "physical line counts", + "label": "Lines of Code" + }, + "maintainability": { + "description": "composite score", + "label": "Maintainability" + } + }, + "cycle_kinds": { + "chain": { + "description": "Three or more units form a strongly-connected component (A → B → C → A); the whole component must be loaded and changed together, defeating modular boundaries.", + "label": "Chain", + "remediation": "Run `code-ranker docs ADP` and follow its instructions." + }, + "mutual": { + "description": "Two units import each other (A ↔ B), so neither can be built, tested, or understood in isolation — the tightest possible coupling.", + "label": "Mutual", + "remediation": "Run `code-ranker docs ADP` and follow its instructions." + } + }, + "cycles": [ + { + "kind": "chain", + "nodes": [ + "{target}/src/chain1.ts", + "{target}/src/chain3.ts", + "{target}/src/chain2.ts" + ] + }, + { + "kind": "mutual", + "nodes": [ + "{target}/src/a.ts", + "{target}/src/b.ts" + ] + } + ], + "edge_attributes": {}, + "edge_kinds": { + "uses": { + "description": "Import dependency — this file imports from the other.", + "flow": true, + "label": "uses" + } + }, + "edges": [ + { + "kind": "uses", + "line": 3, + "source": "{target}/src/a.test.ts", + "target": "{target}/src/a.ts" + }, + { + "kind": "uses", + "line": 20, + "source": "{target}/src/a.ts", + "target": "ext:@scope/util" + }, + { + "kind": "uses", + "line": 18, + "source": "{target}/src/a.ts", + "target": "ext:axios" + }, + { + "kind": "uses", + "line": 16, + "source": "{target}/src/a.ts", + "target": "ext:~utils" + }, + { + "kind": "uses", + "line": 6, + "source": "{target}/src/a.ts", + "target": "{target}/src/b.ts" + }, + { + "kind": "uses", + "line": 8, + "source": "{target}/src/a.ts", + "target": "{target}/src/types.ts" + }, + { + "kind": "uses", + "line": 12, + "source": "{target}/src/a.ts", + "target": "{target}/src/util.ts" + }, + { + "kind": "uses", + "line": 4, + "source": "{target}/src/b.ts", + "target": "{target}/src/a.ts" + }, + { + "kind": "uses", + "line": 6, + "source": "{target}/src/b.ts", + "target": "{target}/src/types.ts" + }, + { + "kind": "uses", + "line": 2, + "source": "{target}/src/chain1.ts", + "target": "{target}/src/chain2.ts" + }, + { + "kind": "uses", + "line": 2, + "source": "{target}/src/chain2.ts", + "target": "{target}/src/chain3.ts" + }, + { + "kind": "uses", + "line": 2, + "source": "{target}/src/chain3.ts", + "target": "{target}/src/chain1.ts" + } + ], + "node_attributes": { + "args": { + "description": "Number of function / closure arguments.", + "direction": "lower_better", + "group": "complexity", + "label": "Args", + "name": "Arguments", + "short": "Args", + "value_type": "int" + }, + "blank": { + "description": "Empty or whitespace-only lines.", + "group": "loc", + "label": "Blank", + "name": "Blank lines", + "short": "Blank", + "value_type": "int" + }, + "branches": { + "description": "Decision points: if / for / while / loop / match arm / try / && / ||. Feeds `cyclomatic`.", + "direction": "lower_better", + "group": "complexity", + "label": "Branches", + "name": "Decision points", + "short": "Branches", + "value_type": "int" + }, + "bugs": { + "calc": "effort ** (2/3) / 3000", + "description": "Estimated delivered bugs — a rough predictor of defect density.", + "direction": "lower_better", + "formula": "effort^⅔ ÷ 3000", + "group": "halstead", + "label": "Bugs", + "name": "Estimated bugs", + "short": "H.bugs", + "value_type": "float" + }, + "cloc": { + "description": "Comment-only lines (inline comments on code lines are not counted).", + "group": "loc", + "label": "Comments", + "name": "Comment lines", + "short": "Comments", + "value_type": "int" + }, + "closures": { + "description": "Number of closures defined in the unit.", + "direction": "lower_better", + "group": "complexity", + "label": "Closures", + "name": "Closures defined", + "short": "Closures", + "value_type": "int" + }, + "cognitive": { + "description": "How hard the code is for a human to follow — not just how many paths it has.
Like `cyclomatic` it adds +1 for each break in linear flow (`if`, `else`, `match`, loops, `catch`, chained `&&` / `||`), but it also adds an extra +1 for every level of nesting: an `if` inside a loop inside an `if` costs far more than three flat `if`s.
That nesting penalty is the point — deeply indented logic is what actually strains a reader, so a high `cognitive` next to a modest `cyclomatic` flags tangled, hard-to-read code.
Summed across every function in the file.", + "direction": "lower_better", + "group": "complexity", + "label": "Cognitive", + "name": "Cognitive complexity", + "short": "Cognitive", + "value_type": "int" + }, + "cycle": { + "description": "Cycle kind this node participates in.", + "group": "coupling", + "label": "Cycle", + "name": "Dependency cycle", + "short": "Cycle", + "value_type": "str" + }, + "cyclomatic": { + "calc": "spaces + branches", + "description": "Number of independent paths through the code — roughly the minimum number of test cases needed to cover every branch.
A function starts at 1 and gains +1 per decision point: each `if` / `else if`, every `match` / `switch` arm, every loop, and each `&&` / `||` in a condition.
Summed across every function in the file, so it grows with both size and branching — the file's total branching burden.
Counts paths only, ignoring how deeply they nest. For a readability-weighted view see `cognitive`.", + "direction": "lower_better", + "formula": "spaces + branches", + "group": "complexity", + "label": "Cyclomatic", + "name": "Cyclomatic complexity", + "omit_at": 1.0, + "short": "Cyclomatic", + "value_type": "int" + }, + "effort": { + "calc": "(eta1 / 2) * (n2 / eta2) * volume", + "description": "Mental effort to implement the algorithm.", + "direction": "lower_better", + "formula": "(eta1 ÷ 2) × (n2 ÷ eta2) × volume", + "group": "halstead", + "label": "Effort", + "name": "Implementation effort", + "short": "H.effort", + "value_type": "float" + }, + "eta1": { + "description": "Distinct operators (η₁): the count of unique operator token kinds. JavaScript / TypeScript count the keywords `export import from as extends new function let var const return delete throw break continue if else switch case default for in of while try catch finally with async await yield`, arithmetic / logical / comparison / assignment operators (`+ - * / % ** ++ -- && || ! == === != !== < <= > >= = += -= *= /= %= **= ?? ?`), bitwise operators (`& | ^ << >> >>> ~`), and punctuation / delimiters (`. , : ; ( [ { @`).", + "direction": "lower_better", + "group": "halstead", + "label": "η₁", + "name": "Unique operators", + "short": "η₁", + "value_type": "int" + }, + "eta2": { + "description": "Distinct operands (η₂): the count of unique operand texts. JavaScript / TypeScript count identifiers (including `member_expression` / `property_identifier` / `nested_identifier`), literals (`string`, `number`, `true`, `false`, `null`, `undefined`, `void`), `this` / `super`, and the contextual keywords `set` / `get` / `typeof` / `instanceof`.", + "direction": "lower_better", + "group": "halstead", + "label": "η₂", + "name": "Unique operands", + "short": "η₂", + "value_type": "int" + }, + "exits": { + "description": "Number of exit points (return/throw) in the unit.", + "direction": "lower_better", + "group": "complexity", + "label": "Exits", + "name": "Exit points", + "short": "Exits", + "value_type": "int" + }, + "external": { + "label": "External", + "value_type": "bool" + }, + "fan_in": { + "description": "Many other units depend on this one, making it risky to change and a single point of failure — though some hubs (shared types) carry high fan-in legitimately.", + "group": "coupling", + "label": "Fan-in", + "name": "Incoming dependencies", + "short": "Fan-in", + "value_type": "int" + }, + "fan_out": { + "description": "This unit depends on many others, so it breaks when any of them change and is hard to test in isolation.", + "group": "coupling", + "label": "Fan-out", + "name": "Outgoing dependencies", + "short": "Fan-out", + "value_type": "int" + }, + "fan_out_external": { + "description": "Number of distinct external libraries this node depends on.", + "group": "coupling", + "label": "Fan-out (external)", + "name": "External dependencies", + "short": "Fan-out (external)", + "value_type": "int" + }, + "hk": { + "abbreviate": true, + "calc": "sloc * (fan_in * fan_out) ** 2", + "description": "Henry-Kafura information-flow complexity: a module that is both a busy crossroads (high fan-in × fan-out) and large — the most expensive place in the codebase to change.", + "direction": "lower_better", + "formula": "sloc × (fan_in × fan_out)²", + "group": "coupling", + "label": "HK", + "name": "God-object risk", + "short": "HK", + "value_type": "float" + }, + "length": { + "calc": "n1 + n2", + "description": "Program length — total operator + operand occurrences.", + "direction": "lower_better", + "formula": "n1 + n2", + "group": "halstead", + "label": "Length", + "name": "Total tokens", + "short": "H.len", + "value_type": "float" + }, + "lloc": { + "description": "Logical lines — counts statements, not physical lines.", + "group": "loc", + "label": "Logical", + "name": "Logical lines", + "short": "Logical", + "value_type": "int" + }, + "loc": { + "description": "Raw file line count, including blank and comment lines (unlike `sloc`). Large files tend to hold several responsibilities and are harder to review, test, and reuse.", + "group": "loc", + "label": "Lines", + "name": "Total lines", + "remediation": "Split by responsibility into smaller units, extract helpers, and separate data definitions from behavior. For an average breach, break up the largest units first (--top).", + "value_type": "int" + }, + "mi": { + "calc": "171 - 5.2*Math.log(volume) - 0.23*cyclomatic - 16.2*Math.log(span_sloc)", + "description": "Maintainability Index (0–100, higher is more maintainable). Derived from Halstead volume, cyclomatic complexity, and SLOC.", + "direction": "higher_better", + "formula": "171 − 5.2·ln(volume) − 0.23·cyclomatic − 16.2·ln(span_sloc)", + "group": "maintainability", + "label": "MI", + "name": "Maintainability index", + "short": "MI", + "value_type": "float" + }, + "mi_sei": { + "calc": "171 - 5.2*Math.log2(volume) - 0.23*cyclomatic - 16.2*Math.log2(span_sloc) + 50*Math.sin(Math.sqrt(cloc / span_sloc * 2.4))", + "description": "SEI variant of the Maintainability Index — adds a bonus for comment density.", + "direction": "higher_better", + "formula": "171 − 5.2·log₂(volume) − 0.23·cyclomatic − 16.2·log₂(span_sloc) + 50·sin(√(cloc ÷ span_sloc × 2.4))", + "group": "maintainability", + "label": "MI (SEI)", + "name": "Maintainability (SEI)", + "short": "MI SEI", + "value_type": "float" + }, + "n1": { + "description": "Total operators (N₁): every operator occurrence counted with repetition (the η₁ tokens, not deduplicated).", + "direction": "lower_better", + "group": "halstead", + "label": "N₁", + "name": "Total operators", + "short": "N₁", + "value_type": "int" + }, + "n2": { + "description": "Total operands (N₂): every operand occurrence counted with repetition (the η₂ texts, not deduplicated).", + "direction": "lower_better", + "group": "halstead", + "label": "N₂", + "name": "Total operands", + "short": "N₂", + "value_type": "int" + }, + "sloc": { + "description": "Source lines of code — lines with at least one non-whitespace, non-comment character. Blank and comment-only lines are not counted (unlike `loc`, the raw file line count).", + "group": "loc", + "label": "Source", + "name": "Source lines", + "short": "SLOC", + "value_type": "int" + }, + "spaces": { + "description": "Unit count: the source file (1) plus each function / impl / trait / closure space. Feeds `cyclomatic`.", + "direction": "lower_better", + "group": "complexity", + "label": "Spaces", + "name": "Code units", + "short": "Spaces", + "value_type": "int" + }, + "span_sloc": { + "description": "Line span of the unit (end_row − start_row) — the size input the Maintainability Index (`mi` / `mi_sei`) is computed from.", + "direction": "lower_better", + "group": "maintainability", + "label": "Span", + "name": "Line span", + "short": "Span", + "value_type": "int" + }, + "time": { + "calc": "effort / 18", + "description": "Estimated implementation time, in seconds.", + "direction": "lower_better", + "formula": "effort ÷ 18", + "group": "halstead", + "label": "Time", + "name": "Coding time (s)", + "short": "H.time(s)", + "value_type": "float" + }, + "visibility": { + "label": "Visibility", + "value_type": "str" + }, + "vocabulary": { + "calc": "eta1 + eta2", + "description": "Vocabulary — distinct operators + operands.", + "direction": "lower_better", + "formula": "eta1 + eta2", + "group": "halstead", + "label": "Vocabulary", + "name": "Distinct symbols", + "short": "H.vocab", + "value_type": "float" + }, + "volume": { + "calc": "length * Math.log2(vocabulary)", + "description": "Algorithm size in bits, from distinct operators and operands.", + "direction": "lower_better", + "formula": "length × log₂(vocabulary)", + "group": "halstead", + "label": "Volume", + "name": "Code volume", + "short": "H.vol", + "value_type": "float" + } + }, + "node_kinds": { + "external": { + "external": true, + "fill": "#f6e2c0", + "label": "Library", + "plural": "Libraries", + "stroke": "#b3801f" + }, + "file": { + "fill": "#dbe9f4", + "label": "File", + "plural": "Files", + "stroke": "#4d6f9c" + } + }, + "nodes": [ + { + "external": true, + "id": "ext:@scope/util", + "kind": "external", + "name": "@scope/util" + }, + { + "external": true, + "id": "ext:axios", + "kind": "external", + "name": "axios" + }, + { + "external": true, + "id": "ext:~utils", + "kind": "external", + "name": "~utils" + }, + { + "blank": 1, + "bugs": 0.0166, + "cloc": 2, + "closures": 1, + "cyclomatic": 2, + "effort": 353.817, + "eta1": 7, + "eta2": 8, + "fan_out": 1, + "id": "{target}/src/a.test.ts", + "kind": "file", + "length": 23, + "lloc": 4, + "loc": 8, + "mi": 115.625, + "mi_sei": 128.146, + "n1": 14, + "n2": 9, + "name": "a.test.ts", + "sloc": 4, + "spaces": 2, + "span_sloc": 7, + "time": 19.656, + "visibility": "public", + "vocabulary": 15, + "volume": 89.858 + }, + { + "blank": 3, + "branches": 2, + "bugs": 0.131, + "cloc": 13, + "cognitive": 2, + "cycle": "mutual", + "cyclomatic": 5, + "effort": 7858.206, + "eta1": 19, + "eta2": 21, + "exits": 2, + "fan_in": 2, + "fan_out": 3, + "fan_out_external": 3, + "hk": 540, + "id": "{target}/src/a.ts", + "kind": "file", + "length": 102, + "lloc": 13, + "loc": 32, + "mi": 81.476, + "mi_sei": 84.513, + "n1": 70, + "n2": 32, + "name": "a.ts", + "sloc": 15, + "spaces": 3, + "span_sloc": 31, + "time": 436.567, + "visibility": "public", + "vocabulary": 40, + "volume": 542.836 + }, + { + "blank": 3, + "bugs": 0.0262, + "cloc": 3, + "cycle": "mutual", + "cyclomatic": 3, + "effort": 700, + "eta1": 10, + "eta2": 6, + "exits": 2, + "fan_in": 1, + "fan_out": 2, + "hk": 32, + "id": "{target}/src/b.ts", + "kind": "file", + "length": 30, + "lloc": 8, + "loc": 15, + "mi": 102.662, + "mi_sei": 105.576, + "n1": 23, + "n2": 7, + "name": "b.ts", + "sloc": 8, + "spaces": 3, + "span_sloc": 14, + "time": 38.888, + "visibility": "public", + "vocabulary": 16, + "volume": 120 + }, + { + "bugs": 0.0163, + "cloc": 1, + "cycle": "chain", + "cyclomatic": 2, + "effort": 344.156, + "eta1": 9, + "eta2": 3, + "exits": 1, + "fan_in": 1, + "fan_out": 1, + "hk": 4, + "id": "{target}/src/chain1.ts", + "kind": "file", + "length": 16, + "lloc": 4, + "loc": 6, + "mi": 123.41, + "mi_sei": 134.482, + "n1": 12, + "n2": 4, + "name": "chain1.ts", + "sloc": 4, + "spaces": 2, + "span_sloc": 5, + "time": 19.119, + "visibility": "public", + "vocabulary": 12, + "volume": 57.359 + }, + { + "bugs": 0.0163, + "cloc": 1, + "cycle": "chain", + "cyclomatic": 2, + "effort": 344.156, + "eta1": 9, + "eta2": 3, + "exits": 1, + "fan_in": 1, + "fan_out": 1, + "hk": 4, + "id": "{target}/src/chain2.ts", + "kind": "file", + "length": 16, + "lloc": 4, + "loc": 6, + "mi": 123.41, + "mi_sei": 134.482, + "n1": 12, + "n2": 4, + "name": "chain2.ts", + "sloc": 4, + "spaces": 2, + "span_sloc": 5, + "time": 19.119, + "visibility": "public", + "vocabulary": 12, + "volume": 57.359 + }, + { + "bugs": 0.0163, + "cloc": 1, + "cycle": "chain", + "cyclomatic": 2, + "effort": 344.156, + "eta1": 9, + "eta2": 3, + "exits": 1, + "fan_in": 1, + "fan_out": 1, + "hk": 4, + "id": "{target}/src/chain3.ts", + "kind": "file", + "length": 16, + "lloc": 4, + "loc": 6, + "mi": 123.41, + "mi_sei": 134.482, + "n1": 12, + "n2": 4, + "name": "chain3.ts", + "sloc": 4, + "spaces": 2, + "span_sloc": 5, + "time": 19.119, + "visibility": "public", + "vocabulary": 12, + "volume": 57.359 + }, + { + "args": 4, + "branches": 4, + "bugs": 0.138, + "cloc": 5, + "cognitive": 5, + "cyclomatic": 7, + "effort": 8441.411, + "eta1": 19, + "eta2": 8, + "exits": 3, + "id": "{target}/src/complex.ts", + "kind": "file", + "length": 65, + "lloc": 11, + "loc": 16, + "mi": 95.705, + "mi_sei": 102.077, + "n1": 42, + "n2": 23, + "name": "complex.ts", + "sloc": 11, + "spaces": 3, + "span_sloc": 15, + "time": 468.967, + "visibility": "public", + "vocabulary": 27, + "volume": 309.067 + }, + { + "bugs": 0.00329, + "cloc": 3, + "effort": 31.019, + "eta1": 4, + "eta2": 2, + "id": "{target}/src/lazy.ts", + "kind": "file", + "length": 6, + "lloc": 1, + "loc": 5, + "mi": 134.056, + "mi_sei": 166.496, + "n1": 4, + "n2": 2, + "name": "lazy.ts", + "sloc": 1, + "spaces": 1, + "span_sloc": 4, + "time": 1.723, + "visibility": "public", + "vocabulary": 6, + "volume": 15.509 + }, + { + "blank": 1, + "bugs": 0.0098, + "cloc": 1, + "cyclomatic": 2, + "effort": 159.452, + "eta1": 8, + "eta2": 2, + "exits": 1, + "fan_in": 2, + "id": "{target}/src/types.ts", + "kind": "file", + "length": 12, + "lloc": 4, + "loc": 7, + "mi": 122.349, + "mi_sei": 130.571, + "n1": 10, + "n2": 2, + "name": "types.ts", + "sloc": 4, + "spaces": 2, + "span_sloc": 6, + "time": 8.858, + "visibility": "public", + "vocabulary": 10, + "volume": 39.863 + }, + { + "blank": 1, + "bugs": 0.012, + "cloc": 1, + "cyclomatic": 3, + "effort": 217.944, + "eta1": 7, + "eta2": 4, + "exits": 2, + "fan_in": 1, + "id": "{target}/src/util.ts", + "kind": "file", + "length": 18, + "lloc": 6, + "loc": 9, + "mi": 115.139, + "mi_sei": 116.752, + "n1": 14, + "n2": 4, + "name": "util.ts", + "sloc": 6, + "spaces": 3, + "span_sloc": 8, + "time": 12.108, + "visibility": "public", + "vocabulary": 11, + "volume": 62.269 + } + ], + "stats": { + "blank": 1.8, + "bugs": 0.0385, + "cloc": 3.1, + "cognitive": 3.5, + "cyclomatic": 3.111, + "effort": 1879.431, + "fan_in": 1.285, + "fan_out": 1.5, + "hk": 116.8, + "length": 30.4, + "mi": 113.724, + "mi_sei": 123.757, + "sloc": 6.1, + "time": 104.412, + "vocabulary": 16.1, + "volume": 135.147 + }, + "ui": { + "card": [ + "hk", + "sloc" + ], + "columns": [ + "kind", + "cycle", + "sloc", + "hk", + "fan_in", + "fan_out", + "volume", + "bugs", + "effort", + "time", + "length", + "vocabulary", + "cyclomatic", + "cognitive", + "mi", + "mi_sei", + "lloc", + "cloc", + "blank" + ], + "default_sort": "cycle", + "filter": [ + "cycle" + ], + "size": [ + "sloc", + "hk" + ], + "sort": [ + "cycle", + "sloc", + "hk", + "fan_in", + "fan_out", + "volume", + "bugs", + "effort", + "time", + "length", + "vocabulary", + "cyclomatic", + "cognitive", + "mi", + "mi_sei", + "lloc", + "cloc", + "blank" + ], + "summary": [ + "sloc", + "hk", + "fan_in", + "fan_out", + "volume", + "bugs", + "effort", + "time", + "length", + "vocabulary", + "cyclomatic", + "cognitive", + "mi", + "mi_sei", + "lloc", + "cloc", + "blank" + ] + } + } + }, + "principles": [ + { + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/ts/CPX.md", + "id": "CPX", + "label": "CPX", + "prompt": "These modules are too complex and I want to reduce their complexity.\nReduce it by splitting large units into smaller single-responsibility ones,\nextracting repeated patterns into shared helpers, flattening deeply nested\ncontrol flow, and breaking large functions into focused helpers.", + "sort_metric": "cognitive", + "title": "CPX — Reduce Complexity" + }, + { + "connections": [ + "common" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/ts/ADP.md", + "id": "ADP", + "label": "ADP", + "prompt": "The dependency graph between modules must form a DAG. When module A depends\non module B, no chain of dependencies should bring B back to A.\n\nIdentify any cycles in the modules below. For each cycle, propose a concrete\nrefactoring (extract a shared abstraction, invert a dependency, split a module)\nthat makes the graph acyclic without breaking existing functionality.\n\nWhen splitting a module to break a cycle, the new structure should:\n- Preserve existing API contracts\n- Minimise coupling in the new structure\n- Follow the Single Responsibility Principle\n- Not introduce new dependency cycles", + "sort_metric": "cycle", + "title": "ADP — Acyclic Dependencies Principle" + }, + { + "connections": [ + "in", + "out" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/ts/SRP.md", + "id": "SRP", + "label": "SRP", + "prompt": "A module should have one reason to change — it should serve one actor\nand encapsulate one coherent set of decisions.\n\nFor each module below, identify whether it has more than one responsibility.\nPropose how to split responsibilities so each module changes for only one reason,\nand specify the new module boundaries.", + "sort_metric": "sloc", + "title": "SRP — Single Responsibility Principle" + }, + { + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/ts/OCP.md", + "id": "OCP", + "label": "OCP", + "prompt": "A module should be open for extension but closed for modification: new behaviour\nshould be addable without editing existing, working code.\n\nFor each module below, identify extension points that currently require editing\nexisting code (e.g. growing match/switch/if-else chains). Propose an extension\nmechanism (polymorphism, strategy, plug-in registration) so new cases can be added\nwithout modifying these modules.", + "sort_metric": "cyclomatic", + "title": "OCP — Open/Closed Principle" + }, + { + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/ts/LSP.md", + "id": "LSP", + "label": "LSP", + "prompt": "Every implementation of an interface must honour its full contract — return-value\ninvariants, error/exception behaviour, side effects, and resource ownership — not\njust the method signatures. A subtype must be substitutable for its base without\nsurprising callers.\n\nIdentify the interface implementations in the modules below. For each, check it can\nreplace any other implementation of the same interface without breaking callers.\nFlag violations and propose fixes.", + "sort_metric": "hk", + "title": "LSP — Liskov Substitution Principle" + }, + { + "connections": [ + "in" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/ts/ISP.md", + "id": "ISP", + "label": "ISP", + "prompt": "Clients should not be forced to depend on methods they do not use. Prefer several\nsmall, focused interfaces over one wide interface.\n\nIdentify interfaces in the modules below that are wider than their consumers need.\nPropose how to split them into narrower interfaces so each consumer depends only on\nwhat it actually uses.", + "sort_metric": "items", + "title": "ISP — Interface Segregation Principle" + }, + { + "connections": [ + "common", + "out" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/ts/DIP.md", + "id": "DIP", + "label": "DIP", + "prompt": "High-level modules should not depend on low-level modules; both should depend on\nabstractions, and abstractions should not depend on details.\n\nFind places in the modules below where a high-level module depends directly on a\nconcrete low-level type. Propose an abstraction (interface) to invert each such\ndependency, and specify where the concrete implementation should be wired in.", + "sort_metric": "fan_out", + "title": "DIP — Dependency Inversion Principle" + }, + { + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/ts/DRY.md", + "id": "DRY", + "label": "DRY", + "prompt": "Every piece of knowledge must have a single authoritative representation.\nDRY is about knowledge duplication, not just code duplication.\n\nIdentify concepts, rules, or policies that are duplicated across the modules\nbelow. For each duplication, propose a canonical location and the refactoring\nneeded to consolidate it.", + "sort_metric": "sloc", + "title": "DRY — Don't Repeat Yourself" + }, + { + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/ts/KISS.md", + "id": "KISS", + "label": "KISS", + "prompt": "When two designs solve the same problem, prefer the simpler one — fewer\nabstractions, fewer indirection layers, fewer moving parts.\n\nIdentify over-engineered or needlessly complex constructs in the modules below.\nFor each, describe the simpler alternative and estimate the risk of simplifying.", + "sort_metric": "cognitive", + "title": "KISS — Keep It Simple" + }, + { + "connections": [ + "common", + "out" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/ts/LoD.md", + "id": "LoD", + "label": "LoD", + "prompt": "A method should only call methods on: itself, its direct fields,\nits parameters, and objects it constructs locally.\nAvoid `x.foo().bar().baz()` chains that traverse object graphs.\n\nIdentify method chains or deep field traversals in the modules below that\nviolate LoD. For each, propose a narrow accessor or a facade that exposes only\nwhat the caller needs, reducing coupling.", + "sort_metric": "fan_out", + "title": "Law of Demeter — Principle of Least Knowledge" + }, + { + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/ts/MISU.md", + "id": "MISU", + "label": "MISU", + "prompt": "Move correctness from runtime checks into the type system, so invalid states\ncannot be constructed and fail at compile time rather than at runtime.\n\nIdentify data structures or function signatures in the modules below where invalid\nstates are representable at runtime. For each, propose a type-level encoding\n(sum type / enum, newtype, typestate) that makes the invalid state unrepresentable\nby construction.", + "sort_metric": "cyclomatic", + "title": "MISU — Make Invalid States Unrepresentable" + }, + { + "connections": [ + "common" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/ts/CoI.md", + "id": "CoI", + "label": "CoI", + "prompt": "Build behaviour by composing small, focused pieces rather than through deep\ninheritance hierarchies.\n\nIdentify large types that accumulate behaviour in the modules below. Propose how to\ndecompose them into smaller composable parts, and show how consumers would assemble\nthe behaviour they need.", + "sort_metric": "items", + "title": "CoI — Composition Over Inheritance" + }, + { + "connections": [ + "out" + ], + "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/plugins/ts/YAGNI.md", + "id": "YAGNI", + "label": "YAGNI", + "prompt": "Build for the problem you have now, not one you imagine you might have later.\nDon't add an abstraction, a generic parameter, or a public API for a hypothetical\nfuture use.\n\nIdentify abstractions, generics, or public APIs in the modules below that were\nadded speculatively. For each, assess whether multiple real callers use it today,\nand propose simplification if not.", + "sort_metric": "sloc", + "title": "YAGNI — You Aren't Gonna Need It" + } + ], + "prompt": { + "cycle_note": "This is **one** dependency cycle; every module in it is listed below so the whole loop is visible. Fix one cycle at a time — `--top 2`+ lists several separate cycles at once and obscures how each one connects.", + "doc_note": "**First, before reading the source**, run `code-ranker docs {id}` — it prints the full principle and, for your language, the usual cause of this exact violation and the smallest correct fix, often with a worked example and how to confirm it. Read it first: it normally names the remedy outright, so you apply it instead of re-deriving the mechanism from the code.", + "focus": "**Focus the research and report primarily on the modules below.**", + "intro": "I want to apply this to some modules in my system.", + "task": [ + "- Prepare a precise, detailed estimate and a report of where the modules below violate it.", + "- If you find more serious violations elsewhere during research, mention them in the report too.", + "- Show a summary of the report in chat.", + "- If any violation is found, suggest saving the report to a file as a plan for a detailed review, named `.code-ranker/-{id}.md`." + ] + } + } + }, + "plugins": [ + "ts" + ], + "roots": { + "target": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/ts/tests/sample" + }, + "schema_version": "5.0", + "target": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/ts/tests/sample", + "timings": [ + { + "detail": "13 nodes from 10 files", + "ms": 0, + "stage": "ts: parse" + }, + { + "detail": "10 nodes annotated", + "ms": 0, + "stage": "ts: complexity" + }, + { + "detail": "nodes=13 edges=12", + "ms": 0, + "stage": "ts: projection" + } + ], + "versions": { + "code-ranker": "5.0.0" + }, + "workspace": "/home/user/code-ranker" +} diff --git a/crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker.toml b/crates/code-ranker-plugins/src/languages/ts/tests/sample/code-ranker.toml similarity index 79% rename from crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker.toml rename to crates/code-ranker-plugins/src/languages/ts/tests/sample/code-ranker.toml index c7bebd14..3aa4832c 100644 --- a/crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker.toml +++ b/crates/code-ranker-plugins/src/languages/ts/tests/sample/code-ranker.toml @@ -1,9 +1,9 @@ -version = "4.0" +version = "5.0" # Self-contained config for the code-ranker "typescript" sample fixture. # Pin the plugin and KEEP test files in the graph (ignore.tests = false) so the # fixture is reproducible regardless of any repo-level config and so that test # files' imports stay visible in the report. -plugin = "typescript" - -[ignore] +[plugins] +enabled = ["typescript"] +[plugins.base.ignore] tests = false diff --git a/crates/code-ranker-plugins/src/languages/typescript/tests/sample/src/a.test.ts b/crates/code-ranker-plugins/src/languages/ts/tests/sample/src/a.test.ts similarity index 100% rename from crates/code-ranker-plugins/src/languages/typescript/tests/sample/src/a.test.ts rename to crates/code-ranker-plugins/src/languages/ts/tests/sample/src/a.test.ts diff --git a/crates/code-ranker-plugins/src/languages/typescript/tests/sample/src/a.ts b/crates/code-ranker-plugins/src/languages/ts/tests/sample/src/a.ts similarity index 100% rename from crates/code-ranker-plugins/src/languages/typescript/tests/sample/src/a.ts rename to crates/code-ranker-plugins/src/languages/ts/tests/sample/src/a.ts diff --git a/crates/code-ranker-plugins/src/languages/typescript/tests/sample/src/b.ts b/crates/code-ranker-plugins/src/languages/ts/tests/sample/src/b.ts similarity index 100% rename from crates/code-ranker-plugins/src/languages/typescript/tests/sample/src/b.ts rename to crates/code-ranker-plugins/src/languages/ts/tests/sample/src/b.ts diff --git a/crates/code-ranker-plugins/src/languages/typescript/tests/sample/src/chain1.ts b/crates/code-ranker-plugins/src/languages/ts/tests/sample/src/chain1.ts similarity index 100% rename from crates/code-ranker-plugins/src/languages/typescript/tests/sample/src/chain1.ts rename to crates/code-ranker-plugins/src/languages/ts/tests/sample/src/chain1.ts diff --git a/crates/code-ranker-plugins/src/languages/typescript/tests/sample/src/chain2.ts b/crates/code-ranker-plugins/src/languages/ts/tests/sample/src/chain2.ts similarity index 100% rename from crates/code-ranker-plugins/src/languages/typescript/tests/sample/src/chain2.ts rename to crates/code-ranker-plugins/src/languages/ts/tests/sample/src/chain2.ts diff --git a/crates/code-ranker-plugins/src/languages/typescript/tests/sample/src/chain3.ts b/crates/code-ranker-plugins/src/languages/ts/tests/sample/src/chain3.ts similarity index 100% rename from crates/code-ranker-plugins/src/languages/typescript/tests/sample/src/chain3.ts rename to crates/code-ranker-plugins/src/languages/ts/tests/sample/src/chain3.ts diff --git a/crates/code-ranker-plugins/src/languages/typescript/tests/sample/src/complex.ts b/crates/code-ranker-plugins/src/languages/ts/tests/sample/src/complex.ts similarity index 100% rename from crates/code-ranker-plugins/src/languages/typescript/tests/sample/src/complex.ts rename to crates/code-ranker-plugins/src/languages/ts/tests/sample/src/complex.ts diff --git a/crates/code-ranker-plugins/src/languages/typescript/tests/sample/src/lazy.ts b/crates/code-ranker-plugins/src/languages/ts/tests/sample/src/lazy.ts similarity index 100% rename from crates/code-ranker-plugins/src/languages/typescript/tests/sample/src/lazy.ts rename to crates/code-ranker-plugins/src/languages/ts/tests/sample/src/lazy.ts diff --git a/crates/code-ranker-plugins/src/languages/typescript/tests/sample/src/types.ts b/crates/code-ranker-plugins/src/languages/ts/tests/sample/src/types.ts similarity index 100% rename from crates/code-ranker-plugins/src/languages/typescript/tests/sample/src/types.ts rename to crates/code-ranker-plugins/src/languages/ts/tests/sample/src/types.ts diff --git a/crates/code-ranker-plugins/src/languages/typescript/tests/sample/src/util.ts b/crates/code-ranker-plugins/src/languages/ts/tests/sample/src/util.ts similarity index 100% rename from crates/code-ranker-plugins/src/languages/typescript/tests/sample/src/util.ts rename to crates/code-ranker-plugins/src/languages/ts/tests/sample/src/util.ts diff --git a/crates/code-ranker-plugins/src/languages/typescript/tests/sample/tsconfig.json b/crates/code-ranker-plugins/src/languages/ts/tests/sample/tsconfig.json similarity index 100% rename from crates/code-ranker-plugins/src/languages/typescript/tests/sample/tsconfig.json rename to crates/code-ranker-plugins/src/languages/ts/tests/sample/tsconfig.json diff --git a/crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker-report.json b/crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker-report.json deleted file mode 100644 index 21747793..00000000 --- a/crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker-report.json +++ /dev/null @@ -1,1029 +0,0 @@ -{ - "command": "code-ranker report crates/code-ranker-plugins/src/languages/typescript/tests/sample --config crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker.toml --output.json.path=crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker-report.json --output.mode quiet", - "config_file": "crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker.toml", - "generated_at": "1970-01-01T00:00:00Z", - "git": { - "branch": "main", - "commit": "000000000000", - "dirty_files": 0, - "origin": "git@example.com:org/repo.git" - }, - "graphs": { - "files": { - "attribute_groups": { - "complexity": { - "description": "per-function branching, nesting & size", - "label": "Complexity" - }, - "coupling": { - "description": "how tightly modules depend on each other", - "label": "Coupling" - }, - "halstead": { - "description": "operator/operand vocabulary & derived effort", - "label": "Halstead" - }, - "loc": { - "description": "physical line counts", - "label": "Lines of Code" - }, - "maintainability": { - "description": "composite score", - "label": "Maintainability" - } - }, - "cycle_kinds": { - "chain": { - "description": "Three or more units form a strongly-connected component (A → B → C → A); the whole component must be loaded and changed together, defeating modular boundaries.", - "label": "Chain", - "remediation": "Run `code-ranker docs ADP` and follow its instructions." - }, - "mutual": { - "description": "Two units import each other (A ↔ B), so neither can be built, tested, or understood in isolation — the tightest possible coupling.", - "label": "Mutual", - "remediation": "Run `code-ranker docs ADP` and follow its instructions." - } - }, - "cycles": [ - { - "kind": "chain", - "nodes": [ - "{target}/src/chain1.ts", - "{target}/src/chain3.ts", - "{target}/src/chain2.ts" - ] - }, - { - "kind": "mutual", - "nodes": [ - "{target}/src/a.ts", - "{target}/src/b.ts" - ] - } - ], - "edge_attributes": {}, - "edge_kinds": { - "uses": { - "description": "Import dependency — this file imports from the other.", - "flow": true, - "label": "uses" - } - }, - "edges": [ - { - "kind": "uses", - "line": 3, - "source": "{target}/src/a.test.ts", - "target": "{target}/src/a.ts" - }, - { - "kind": "uses", - "line": 20, - "source": "{target}/src/a.ts", - "target": "ext:@scope/util" - }, - { - "kind": "uses", - "line": 18, - "source": "{target}/src/a.ts", - "target": "ext:axios" - }, - { - "kind": "uses", - "line": 16, - "source": "{target}/src/a.ts", - "target": "ext:~utils" - }, - { - "kind": "uses", - "line": 6, - "source": "{target}/src/a.ts", - "target": "{target}/src/b.ts" - }, - { - "kind": "uses", - "line": 8, - "source": "{target}/src/a.ts", - "target": "{target}/src/types.ts" - }, - { - "kind": "uses", - "line": 12, - "source": "{target}/src/a.ts", - "target": "{target}/src/util.ts" - }, - { - "kind": "uses", - "line": 4, - "source": "{target}/src/b.ts", - "target": "{target}/src/a.ts" - }, - { - "kind": "uses", - "line": 6, - "source": "{target}/src/b.ts", - "target": "{target}/src/types.ts" - }, - { - "kind": "uses", - "line": 2, - "source": "{target}/src/chain1.ts", - "target": "{target}/src/chain2.ts" - }, - { - "kind": "uses", - "line": 2, - "source": "{target}/src/chain2.ts", - "target": "{target}/src/chain3.ts" - }, - { - "kind": "uses", - "line": 2, - "source": "{target}/src/chain3.ts", - "target": "{target}/src/chain1.ts" - } - ], - "node_attributes": { - "args": { - "description": "Number of function / closure arguments.", - "direction": "lower_better", - "group": "complexity", - "label": "Args", - "name": "Arguments", - "short": "Args", - "value_type": "int" - }, - "blank": { - "description": "Empty or whitespace-only lines.", - "group": "loc", - "label": "Blank", - "name": "Blank lines", - "short": "Blank", - "value_type": "int" - }, - "branches": { - "description": "Decision points: if / for / while / loop / match arm / try / && / ||. Feeds `cyclomatic`.", - "direction": "lower_better", - "group": "complexity", - "label": "Branches", - "name": "Decision points", - "short": "Branches", - "value_type": "int" - }, - "bugs": { - "calc": "effort ** (2/3) / 3000", - "description": "Estimated delivered bugs — a rough predictor of defect density.", - "direction": "lower_better", - "formula": "effort^⅔ ÷ 3000", - "group": "halstead", - "label": "Bugs", - "name": "Estimated bugs", - "short": "H.bugs", - "value_type": "float" - }, - "cloc": { - "description": "Comment-only lines (inline comments on code lines are not counted).", - "group": "loc", - "label": "Comments", - "name": "Comment lines", - "short": "Comments", - "value_type": "int" - }, - "closures": { - "description": "Number of closures defined in the unit.", - "direction": "lower_better", - "group": "complexity", - "label": "Closures", - "name": "Closures defined", - "short": "Closures", - "value_type": "int" - }, - "cognitive": { - "description": "How hard the code is for a human to follow — not just how many paths it has.
Like `cyclomatic` it adds +1 for each break in linear flow (`if`, `else`, `match`, loops, `catch`, chained `&&` / `||`), but it also adds an extra +1 for every level of nesting: an `if` inside a loop inside an `if` costs far more than three flat `if`s.
That nesting penalty is the point — deeply indented logic is what actually strains a reader, so a high `cognitive` next to a modest `cyclomatic` flags tangled, hard-to-read code.
Summed across every function in the file.", - "direction": "lower_better", - "group": "complexity", - "label": "Cognitive", - "name": "Cognitive complexity", - "short": "Cognitive", - "value_type": "int" - }, - "cycle": { - "description": "Cycle kind this node participates in.", - "group": "coupling", - "label": "Cycle", - "name": "Dependency cycle", - "short": "Cycle", - "value_type": "str" - }, - "cyclomatic": { - "calc": "spaces + branches", - "description": "Number of independent paths through the code — roughly the minimum number of test cases needed to cover every branch.
A function starts at 1 and gains +1 per decision point: each `if` / `else if`, every `match` / `switch` arm, every loop, and each `&&` / `||` in a condition.
Summed across every function in the file, so it grows with both size and branching — the file's total branching burden.
Counts paths only, ignoring how deeply they nest. For a readability-weighted view see `cognitive`.", - "direction": "lower_better", - "formula": "spaces + branches", - "group": "complexity", - "label": "Cyclomatic", - "name": "Cyclomatic complexity", - "omit_at": 1.0, - "short": "Cyclomatic", - "value_type": "int" - }, - "effort": { - "calc": "(eta1 / 2) * (n2 / eta2) * volume", - "description": "Mental effort to implement the algorithm.", - "direction": "lower_better", - "formula": "(eta1 ÷ 2) × (n2 ÷ eta2) × volume", - "group": "halstead", - "label": "Effort", - "name": "Implementation effort", - "short": "H.effort", - "value_type": "float" - }, - "eta1": { - "description": "Distinct operators (η₁): the count of unique operator token kinds. JavaScript / TypeScript count the keywords `export import from as extends new function let var const return delete throw break continue if else switch case default for in of while try catch finally with async await yield`, arithmetic / logical / comparison / assignment operators (`+ - * / % ** ++ -- && || ! == === != !== < <= > >= = += -= *= /= %= **= ?? ?`), bitwise operators (`& | ^ << >> >>> ~`), and punctuation / delimiters (`. , : ; ( [ { @`).", - "direction": "lower_better", - "group": "halstead", - "label": "η₁", - "name": "Unique operators", - "short": "η₁", - "value_type": "int" - }, - "eta2": { - "description": "Distinct operands (η₂): the count of unique operand texts. JavaScript / TypeScript count identifiers (including `member_expression` / `property_identifier` / `nested_identifier`), literals (`string`, `number`, `true`, `false`, `null`, `undefined`, `void`), `this` / `super`, and the contextual keywords `set` / `get` / `typeof` / `instanceof`.", - "direction": "lower_better", - "group": "halstead", - "label": "η₂", - "name": "Unique operands", - "short": "η₂", - "value_type": "int" - }, - "exits": { - "description": "Number of exit points (return/throw) in the unit.", - "direction": "lower_better", - "group": "complexity", - "label": "Exits", - "name": "Exit points", - "short": "Exits", - "value_type": "int" - }, - "external": { - "label": "External", - "value_type": "bool" - }, - "fan_in": { - "description": "Many other units depend on this one, making it risky to change and a single point of failure — though some hubs (shared types) carry high fan-in legitimately.", - "group": "coupling", - "label": "Fan-in", - "name": "Incoming dependencies", - "short": "Fan-in", - "value_type": "int" - }, - "fan_out": { - "description": "This unit depends on many others, so it breaks when any of them change and is hard to test in isolation.", - "group": "coupling", - "label": "Fan-out", - "name": "Outgoing dependencies", - "short": "Fan-out", - "value_type": "int" - }, - "fan_out_external": { - "description": "Number of distinct external libraries this node depends on.", - "group": "coupling", - "label": "Fan-out (external)", - "name": "External dependencies", - "short": "Fan-out (external)", - "value_type": "int" - }, - "hk": { - "abbreviate": true, - "calc": "sloc * (fan_in * fan_out) ** 2", - "description": "Henry-Kafura information-flow complexity: a module that is both a busy crossroads (high fan-in × fan-out) and large — the most expensive place in the codebase to change.", - "direction": "lower_better", - "formula": "sloc × (fan_in × fan_out)²", - "group": "coupling", - "label": "HK", - "name": "God-object risk", - "short": "HK", - "value_type": "float" - }, - "length": { - "calc": "n1 + n2", - "description": "Program length — total operator + operand occurrences.", - "direction": "lower_better", - "formula": "n1 + n2", - "group": "halstead", - "label": "Length", - "name": "Total tokens", - "short": "H.len", - "value_type": "float" - }, - "lloc": { - "description": "Logical lines — counts statements, not physical lines.", - "group": "loc", - "label": "Logical", - "name": "Logical lines", - "short": "Logical", - "value_type": "int" - }, - "loc": { - "description": "Raw file line count, including blank and comment lines (unlike `sloc`). Large files tend to hold several responsibilities and are harder to review, test, and reuse.", - "group": "loc", - "label": "Lines", - "name": "Total lines", - "remediation": "Split by responsibility into smaller units, extract helpers, and separate data definitions from behavior. For an average breach, break up the largest units first (--top).", - "value_type": "int" - }, - "mi": { - "calc": "171 - 5.2*Math.log(volume) - 0.23*cyclomatic - 16.2*Math.log(span_sloc)", - "description": "Maintainability Index (0–100, higher is more maintainable). Derived from Halstead volume, cyclomatic complexity, and SLOC.", - "direction": "higher_better", - "formula": "171 − 5.2·ln(volume) − 0.23·cyclomatic − 16.2·ln(span_sloc)", - "group": "maintainability", - "label": "MI", - "name": "Maintainability index", - "short": "MI", - "value_type": "float" - }, - "mi_sei": { - "calc": "171 - 5.2*Math.log2(volume) - 0.23*cyclomatic - 16.2*Math.log2(span_sloc) + 50*Math.sin(Math.sqrt(cloc / span_sloc * 2.4))", - "description": "SEI variant of the Maintainability Index — adds a bonus for comment density.", - "direction": "higher_better", - "formula": "171 − 5.2·log₂(volume) − 0.23·cyclomatic − 16.2·log₂(span_sloc) + 50·sin(√(cloc ÷ span_sloc × 2.4))", - "group": "maintainability", - "label": "MI (SEI)", - "name": "Maintainability (SEI)", - "short": "MI SEI", - "value_type": "float" - }, - "n1": { - "description": "Total operators (N₁): every operator occurrence counted with repetition (the η₁ tokens, not deduplicated).", - "direction": "lower_better", - "group": "halstead", - "label": "N₁", - "name": "Total operators", - "short": "N₁", - "value_type": "int" - }, - "n2": { - "description": "Total operands (N₂): every operand occurrence counted with repetition (the η₂ texts, not deduplicated).", - "direction": "lower_better", - "group": "halstead", - "label": "N₂", - "name": "Total operands", - "short": "N₂", - "value_type": "int" - }, - "sloc": { - "description": "Source lines of code — lines with at least one non-whitespace, non-comment character. Blank and comment-only lines are not counted (unlike `loc`, the raw file line count).", - "group": "loc", - "label": "Source", - "name": "Source lines", - "short": "SLOC", - "value_type": "int" - }, - "spaces": { - "description": "Unit count: the source file (1) plus each function / impl / trait / closure space. Feeds `cyclomatic`.", - "direction": "lower_better", - "group": "complexity", - "label": "Spaces", - "name": "Code units", - "short": "Spaces", - "value_type": "int" - }, - "span_sloc": { - "description": "Line span of the unit (end_row − start_row) — the size input the Maintainability Index (`mi` / `mi_sei`) is computed from.", - "direction": "lower_better", - "group": "maintainability", - "label": "Span", - "name": "Line span", - "short": "Span", - "value_type": "int" - }, - "time": { - "calc": "effort / 18", - "description": "Estimated implementation time, in seconds.", - "direction": "lower_better", - "formula": "effort ÷ 18", - "group": "halstead", - "label": "Time", - "name": "Coding time (s)", - "short": "H.time(s)", - "value_type": "float" - }, - "visibility": { - "label": "Visibility", - "value_type": "str" - }, - "vocabulary": { - "calc": "eta1 + eta2", - "description": "Vocabulary — distinct operators + operands.", - "direction": "lower_better", - "formula": "eta1 + eta2", - "group": "halstead", - "label": "Vocabulary", - "name": "Distinct symbols", - "short": "H.vocab", - "value_type": "float" - }, - "volume": { - "calc": "length * Math.log2(vocabulary)", - "description": "Algorithm size in bits, from distinct operators and operands.", - "direction": "lower_better", - "formula": "length × log₂(vocabulary)", - "group": "halstead", - "label": "Volume", - "name": "Code volume", - "short": "H.vol", - "value_type": "float" - } - }, - "node_kinds": { - "external": { - "external": true, - "fill": "#f6e2c0", - "label": "Library", - "plural": "Libraries", - "stroke": "#b3801f" - }, - "file": { - "fill": "#dbe9f4", - "label": "File", - "plural": "Files", - "stroke": "#4d6f9c" - } - }, - "nodes": [ - { - "external": true, - "id": "ext:@scope/util", - "kind": "external", - "name": "@scope/util" - }, - { - "external": true, - "id": "ext:axios", - "kind": "external", - "name": "axios" - }, - { - "external": true, - "id": "ext:~utils", - "kind": "external", - "name": "~utils" - }, - { - "blank": 1, - "bugs": 0.0166, - "cloc": 2, - "closures": 1, - "cyclomatic": 2, - "effort": 353.817, - "eta1": 7, - "eta2": 8, - "fan_out": 1, - "id": "{target}/src/a.test.ts", - "kind": "file", - "length": 23, - "lloc": 4, - "loc": 8, - "mi": 115.625, - "mi_sei": 128.146, - "n1": 14, - "n2": 9, - "name": "a.test.ts", - "sloc": 4, - "spaces": 2, - "span_sloc": 7, - "time": 19.656, - "visibility": "public", - "vocabulary": 15, - "volume": 89.858 - }, - { - "blank": 3, - "branches": 2, - "bugs": 0.131, - "cloc": 13, - "cognitive": 2, - "cycle": "mutual", - "cyclomatic": 5, - "effort": 7858.206, - "eta1": 19, - "eta2": 21, - "exits": 2, - "fan_in": 2, - "fan_out": 3, - "fan_out_external": 3, - "hk": 540, - "id": "{target}/src/a.ts", - "kind": "file", - "length": 102, - "lloc": 13, - "loc": 32, - "mi": 81.476, - "mi_sei": 84.513, - "n1": 70, - "n2": 32, - "name": "a.ts", - "sloc": 15, - "spaces": 3, - "span_sloc": 31, - "time": 436.567, - "visibility": "public", - "vocabulary": 40, - "volume": 542.836 - }, - { - "blank": 3, - "bugs": 0.0262, - "cloc": 3, - "cycle": "mutual", - "cyclomatic": 3, - "effort": 700, - "eta1": 10, - "eta2": 6, - "exits": 2, - "fan_in": 1, - "fan_out": 2, - "hk": 32, - "id": "{target}/src/b.ts", - "kind": "file", - "length": 30, - "lloc": 8, - "loc": 15, - "mi": 102.662, - "mi_sei": 105.576, - "n1": 23, - "n2": 7, - "name": "b.ts", - "sloc": 8, - "spaces": 3, - "span_sloc": 14, - "time": 38.888, - "visibility": "public", - "vocabulary": 16, - "volume": 120 - }, - { - "bugs": 0.0163, - "cloc": 1, - "cycle": "chain", - "cyclomatic": 2, - "effort": 344.156, - "eta1": 9, - "eta2": 3, - "exits": 1, - "fan_in": 1, - "fan_out": 1, - "hk": 4, - "id": "{target}/src/chain1.ts", - "kind": "file", - "length": 16, - "lloc": 4, - "loc": 6, - "mi": 123.41, - "mi_sei": 134.482, - "n1": 12, - "n2": 4, - "name": "chain1.ts", - "sloc": 4, - "spaces": 2, - "span_sloc": 5, - "time": 19.119, - "visibility": "public", - "vocabulary": 12, - "volume": 57.359 - }, - { - "bugs": 0.0163, - "cloc": 1, - "cycle": "chain", - "cyclomatic": 2, - "effort": 344.156, - "eta1": 9, - "eta2": 3, - "exits": 1, - "fan_in": 1, - "fan_out": 1, - "hk": 4, - "id": "{target}/src/chain2.ts", - "kind": "file", - "length": 16, - "lloc": 4, - "loc": 6, - "mi": 123.41, - "mi_sei": 134.482, - "n1": 12, - "n2": 4, - "name": "chain2.ts", - "sloc": 4, - "spaces": 2, - "span_sloc": 5, - "time": 19.119, - "visibility": "public", - "vocabulary": 12, - "volume": 57.359 - }, - { - "bugs": 0.0163, - "cloc": 1, - "cycle": "chain", - "cyclomatic": 2, - "effort": 344.156, - "eta1": 9, - "eta2": 3, - "exits": 1, - "fan_in": 1, - "fan_out": 1, - "hk": 4, - "id": "{target}/src/chain3.ts", - "kind": "file", - "length": 16, - "lloc": 4, - "loc": 6, - "mi": 123.41, - "mi_sei": 134.482, - "n1": 12, - "n2": 4, - "name": "chain3.ts", - "sloc": 4, - "spaces": 2, - "span_sloc": 5, - "time": 19.119, - "visibility": "public", - "vocabulary": 12, - "volume": 57.359 - }, - { - "args": 4, - "branches": 4, - "bugs": 0.138, - "cloc": 5, - "cognitive": 5, - "cyclomatic": 7, - "effort": 8441.411, - "eta1": 19, - "eta2": 8, - "exits": 3, - "id": "{target}/src/complex.ts", - "kind": "file", - "length": 65, - "lloc": 11, - "loc": 16, - "mi": 95.705, - "mi_sei": 102.077, - "n1": 42, - "n2": 23, - "name": "complex.ts", - "sloc": 11, - "spaces": 3, - "span_sloc": 15, - "time": 468.967, - "visibility": "public", - "vocabulary": 27, - "volume": 309.067 - }, - { - "bugs": 0.00329, - "cloc": 3, - "effort": 31.019, - "eta1": 4, - "eta2": 2, - "id": "{target}/src/lazy.ts", - "kind": "file", - "length": 6, - "lloc": 1, - "loc": 5, - "mi": 134.056, - "mi_sei": 166.496, - "n1": 4, - "n2": 2, - "name": "lazy.ts", - "sloc": 1, - "spaces": 1, - "span_sloc": 4, - "time": 1.723, - "visibility": "public", - "vocabulary": 6, - "volume": 15.509 - }, - { - "blank": 1, - "bugs": 0.0098, - "cloc": 1, - "cyclomatic": 2, - "effort": 159.452, - "eta1": 8, - "eta2": 2, - "exits": 1, - "fan_in": 2, - "id": "{target}/src/types.ts", - "kind": "file", - "length": 12, - "lloc": 4, - "loc": 7, - "mi": 122.349, - "mi_sei": 130.571, - "n1": 10, - "n2": 2, - "name": "types.ts", - "sloc": 4, - "spaces": 2, - "span_sloc": 6, - "time": 8.858, - "visibility": "public", - "vocabulary": 10, - "volume": 39.863 - }, - { - "blank": 1, - "bugs": 0.012, - "cloc": 1, - "cyclomatic": 3, - "effort": 217.944, - "eta1": 7, - "eta2": 4, - "exits": 2, - "fan_in": 1, - "id": "{target}/src/util.ts", - "kind": "file", - "length": 18, - "lloc": 6, - "loc": 9, - "mi": 115.139, - "mi_sei": 116.752, - "n1": 14, - "n2": 4, - "name": "util.ts", - "sloc": 6, - "spaces": 3, - "span_sloc": 8, - "time": 12.108, - "visibility": "public", - "vocabulary": 11, - "volume": 62.269 - } - ], - "stats": { - "blank": 1.8, - "bugs": 0.0385, - "cloc": 3.1, - "cognitive": 3.5, - "cyclomatic": 3.111, - "effort": 1879.431, - "fan_in": 1.285, - "fan_out": 1.5, - "hk": 116.8, - "length": 30.4, - "mi": 113.724, - "mi_sei": 123.757, - "sloc": 6.1, - "time": 104.412, - "vocabulary": 16.1, - "volume": 135.147 - }, - "ui": { - "card": [ - "hk", - "sloc" - ], - "columns": [ - "kind", - "cycle", - "sloc", - "hk", - "fan_in", - "fan_out", - "volume", - "bugs", - "effort", - "time", - "length", - "vocabulary", - "cyclomatic", - "cognitive", - "mi", - "mi_sei", - "lloc", - "cloc", - "blank" - ], - "default_sort": "cycle", - "filter": [ - "cycle" - ], - "size": [ - "sloc", - "hk" - ], - "sort": [ - "cycle", - "sloc", - "hk", - "fan_in", - "fan_out", - "volume", - "bugs", - "effort", - "time", - "length", - "vocabulary", - "cyclomatic", - "cognitive", - "mi", - "mi_sei", - "lloc", - "cloc", - "blank" - ], - "summary": [ - "sloc", - "hk", - "fan_in", - "fan_out", - "volume", - "bugs", - "effort", - "time", - "length", - "vocabulary", - "cyclomatic", - "cognitive", - "mi", - "mi_sei", - "lloc", - "cloc", - "blank" - ] - } - } - }, - "plugin": "typescript", - "principles": [ - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/typescript/CPX.md", - "id": "CPX", - "label": "CPX", - "prompt": "These modules are too complex and I want to reduce their complexity.\nReduce it by splitting large units into smaller single-responsibility ones,\nextracting repeated patterns into shared helpers, flattening deeply nested\ncontrol flow, and breaking large functions into focused helpers.", - "sort_metric": "cognitive", - "title": "CPX — Reduce Complexity" - }, - { - "connections": [ - "common" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/typescript/ADP.md", - "id": "ADP", - "label": "ADP", - "prompt": "The dependency graph between modules must form a DAG. When module A depends\non module B, no chain of dependencies should bring B back to A.\n\nIdentify any cycles in the modules below. For each cycle, propose a concrete\nrefactoring (extract a shared abstraction, invert a dependency, split a module)\nthat makes the graph acyclic without breaking existing functionality.\n\nWhen splitting a module to break a cycle, the new structure should:\n- Preserve existing API contracts\n- Minimise coupling in the new structure\n- Follow the Single Responsibility Principle\n- Not introduce new dependency cycles", - "sort_metric": "cycle", - "title": "ADP — Acyclic Dependencies Principle" - }, - { - "connections": [ - "in", - "out" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/typescript/SRP.md", - "id": "SRP", - "label": "SRP", - "prompt": "A module should have one reason to change — it should serve one actor\nand encapsulate one coherent set of decisions.\n\nFor each module below, identify whether it has more than one responsibility.\nPropose how to split responsibilities so each module changes for only one reason,\nand specify the new module boundaries.", - "sort_metric": "sloc", - "title": "SRP — Single Responsibility Principle" - }, - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/typescript/OCP.md", - "id": "OCP", - "label": "OCP", - "prompt": "A module should be open for extension but closed for modification: new behaviour\nshould be addable without editing existing, working code.\n\nFor each module below, identify extension points that currently require editing\nexisting code (e.g. growing match/switch/if-else chains). Propose an extension\nmechanism (polymorphism, strategy, plug-in registration) so new cases can be added\nwithout modifying these modules.", - "sort_metric": "cyclomatic", - "title": "OCP — Open/Closed Principle" - }, - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/typescript/LSP.md", - "id": "LSP", - "label": "LSP", - "prompt": "Every implementation of an interface must honour its full contract — return-value\ninvariants, error/exception behaviour, side effects, and resource ownership — not\njust the method signatures. A subtype must be substitutable for its base without\nsurprising callers.\n\nIdentify the interface implementations in the modules below. For each, check it can\nreplace any other implementation of the same interface without breaking callers.\nFlag violations and propose fixes.", - "sort_metric": "hk", - "title": "LSP — Liskov Substitution Principle" - }, - { - "connections": [ - "in" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/typescript/ISP.md", - "id": "ISP", - "label": "ISP", - "prompt": "Clients should not be forced to depend on methods they do not use. Prefer several\nsmall, focused interfaces over one wide interface.\n\nIdentify interfaces in the modules below that are wider than their consumers need.\nPropose how to split them into narrower interfaces so each consumer depends only on\nwhat it actually uses.", - "sort_metric": "items", - "title": "ISP — Interface Segregation Principle" - }, - { - "connections": [ - "common", - "out" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/typescript/DIP.md", - "id": "DIP", - "label": "DIP", - "prompt": "High-level modules should not depend on low-level modules; both should depend on\nabstractions, and abstractions should not depend on details.\n\nFind places in the modules below where a high-level module depends directly on a\nconcrete low-level type. Propose an abstraction (interface) to invert each such\ndependency, and specify where the concrete implementation should be wired in.", - "sort_metric": "fan_out", - "title": "DIP — Dependency Inversion Principle" - }, - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/typescript/DRY.md", - "id": "DRY", - "label": "DRY", - "prompt": "Every piece of knowledge must have a single authoritative representation.\nDRY is about knowledge duplication, not just code duplication.\n\nIdentify concepts, rules, or policies that are duplicated across the modules\nbelow. For each duplication, propose a canonical location and the refactoring\nneeded to consolidate it.", - "sort_metric": "sloc", - "title": "DRY — Don't Repeat Yourself" - }, - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/typescript/KISS.md", - "id": "KISS", - "label": "KISS", - "prompt": "When two designs solve the same problem, prefer the simpler one — fewer\nabstractions, fewer indirection layers, fewer moving parts.\n\nIdentify over-engineered or needlessly complex constructs in the modules below.\nFor each, describe the simpler alternative and estimate the risk of simplifying.", - "sort_metric": "cognitive", - "title": "KISS — Keep It Simple" - }, - { - "connections": [ - "common", - "out" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/typescript/LoD.md", - "id": "LoD", - "label": "LoD", - "prompt": "A method should only call methods on: itself, its direct fields,\nits parameters, and objects it constructs locally.\nAvoid `x.foo().bar().baz()` chains that traverse object graphs.\n\nIdentify method chains or deep field traversals in the modules below that\nviolate LoD. For each, propose a narrow accessor or a facade that exposes only\nwhat the caller needs, reducing coupling.", - "sort_metric": "fan_out", - "title": "Law of Demeter — Principle of Least Knowledge" - }, - { - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/typescript/MISU.md", - "id": "MISU", - "label": "MISU", - "prompt": "Move correctness from runtime checks into the type system, so invalid states\ncannot be constructed and fail at compile time rather than at runtime.\n\nIdentify data structures or function signatures in the modules below where invalid\nstates are representable at runtime. For each, propose a type-level encoding\n(sum type / enum, newtype, typestate) that makes the invalid state unrepresentable\nby construction.", - "sort_metric": "cyclomatic", - "title": "MISU — Make Invalid States Unrepresentable" - }, - { - "connections": [ - "common" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/typescript/CoI.md", - "id": "CoI", - "label": "CoI", - "prompt": "Build behaviour by composing small, focused pieces rather than through deep\ninheritance hierarchies.\n\nIdentify large types that accumulate behaviour in the modules below. Propose how to\ndecompose them into smaller composable parts, and show how consumers would assemble\nthe behaviour they need.", - "sort_metric": "items", - "title": "CoI — Composition Over Inheritance" - }, - { - "connections": [ - "out" - ], - "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/typescript/YAGNI.md", - "id": "YAGNI", - "label": "YAGNI", - "prompt": "Build for the problem you have now, not one you imagine you might have later.\nDon't add an abstraction, a generic parameter, or a public API for a hypothetical\nfuture use.\n\nIdentify abstractions, generics, or public APIs in the modules below that were\nadded speculatively. For each, assess whether multiple real callers use it today,\nand propose simplification if not.", - "sort_metric": "sloc", - "title": "YAGNI — You Aren't Gonna Need It" - } - ], - "prompt": { - "cycle_note": "This is **one** dependency cycle; every module in it is listed below so the whole loop is visible. Fix one cycle at a time — `--top 2`+ lists several separate cycles at once and obscures how each one connects.", - "doc_note": "**First, before reading the source**, run `code-ranker docs {id}` — it prints the full principle and, for your language, the usual cause of this exact violation and the smallest correct fix, often with a worked example and how to confirm it. Read it first: it normally names the remedy outright, so you apply it instead of re-deriving the mechanism from the code.", - "focus": "**Focus the research and report primarily on the modules below.**", - "intro": "I want to apply this to some modules in my system.", - "task": [ - "- Prepare a precise, detailed estimate and a report of where the modules below violate it.", - "- If you find more serious violations elsewhere during research, mention them in the report too.", - "- Show a summary of the report in chat.", - "- If any violation is found, suggest saving the report to a file as a plan for a detailed review, named `.code-ranker/-{id}.md`." - ] - }, - "roots": { - "target": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/typescript/tests/sample" - }, - "schema_version": "4.0", - "target": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/typescript/tests/sample", - "timings": [ - { - "detail": "13 nodes from 10 files", - "ms": 0, - "stage": "typescript" - }, - { - "detail": "10 nodes annotated", - "ms": 0, - "stage": "complexity" - }, - { - "detail": "nodes=13 edges=12", - "ms": 0, - "stage": "projection" - } - ], - "versions": { - "code-ranker": "4.0.0" - }, - "workspace": "/home/user/code-ranker" -} diff --git a/crates/code-ranker-plugins/src/lib.rs b/crates/code-ranker-plugins/src/lib.rs index 73473dbc..9fc5eaaf 100644 --- a/crates/code-ranker-plugins/src/lib.rs +++ b/crates/code-ranker-plugins/src/lib.rs @@ -1,7 +1,7 @@ //! Code Ranker language plugins, merged into one crate. //! //! Each language lives in its own module under [`languages`] (`rust`, `python`, -//! `javascript`, `typescript`); the JavaScript and TypeScript plugins share the +//! `js`, `ts`); the JavaScript and TypeScript plugins share the //! grammar-agnostic engine in [`languages::ecmascript`]. The four plugin structs //! are re-exported at the crate root (e.g. [`RustPlugin`]). @@ -19,11 +19,11 @@ pub use languages::c::CPlugin; pub use languages::cpp::CppPlugin; pub use languages::csharp::CsharpPlugin; pub use languages::go::GoPlugin; -pub use languages::javascript::JavascriptPlugin; -pub use languages::markdown::MarkdownPlugin; +pub use languages::js::JsPlugin; +pub use languages::md::MdPlugin; pub use languages::python::PythonPlugin; pub use languages::rust::RustPlugin; -pub use languages::typescript::TypescriptPlugin; +pub use languages::ts::TsPlugin; // Each plugin self-registers via `inventory::submit!` in its module; consumers get // the full set through `code_ranker_plugin_api::registry()` and a plugin's merged diff --git a/crates/code-ranker-plugins/src/tests/config.rs b/crates/code-ranker-plugins/src/tests/config.rs index eadabc22..997522ce 100644 --- a/crates/code-ranker-plugins/src/tests/config.rs +++ b/crates/code-ranker-plugins/src/tests/config.rs @@ -252,11 +252,11 @@ fn string_table_reads_structure_node_kinds() { /// be transcribed exactly (the e2e goldens depend on them). #[test] fn string_list_reads_data_lists_verbatim() { - let js = load(include_str!("../languages/javascript/config.toml")); + let js = load(include_str!("../languages/js/config.toml")); assert_eq!(string_list(&js, "extensions"), ["js", "jsx", "mjs", "cjs"]); assert_eq!(string_list(&js, "detect_markers"), ["package.json"]); - let ts = load(include_str!("../languages/typescript/config.toml")); + let ts = load(include_str!("../languages/ts/config.toml")); assert_eq!(string_list(&ts, "extensions"), ["ts", "tsx", "mts", "cts"]); // resolution_order is significant: TS-first, then JS fallbacks. assert_eq!( @@ -336,7 +336,7 @@ fn resolved_principles_inherit_catalog_and_resolve_doc_urls() { assert_eq!(cpx.label, "CPX"); assert_eq!( cpx.doc_url.as_deref(), - Some("https://github.com/ffedoroff/code-ranker/blob/main/languages/rust/CPX.md") + Some("https://github.com/ffedoroff/code-ranker/blob/main/plugins/rust/CPX.md") ); // Languages with no own principles inherit the full 13-entry catalog, and JS @@ -344,9 +344,9 @@ fn resolved_principles_inherit_catalog_and_resolve_doc_urls() { let py = resolved_principles(&load(include_str!("../languages/python/config.toml"))); assert_eq!(py.len(), 13); assert!(py[0].doc_url.as_deref().unwrap().contains("/python/")); - let js = resolved_principles(&load(include_str!("../languages/javascript/config.toml"))); + let js = resolved_principles(&load(include_str!("../languages/js/config.toml"))); assert_eq!(js.len(), 13); - assert!(js[0].doc_url.as_deref().unwrap().contains("/typescript/")); + assert!(js[0].doc_url.as_deref().unwrap().contains("/ts/")); // A language with no own corpus (no `doc_overrides`) inherits every doc from // the shared `base/` fallback — fixing what used to be a dead `/go/` link. @@ -354,7 +354,7 @@ fn resolved_principles_inherit_catalog_and_resolve_doc_urls() { assert_eq!(go.len(), 13); assert_eq!( go[0].doc_url.as_deref(), - Some("https://github.com/ffedoroff/code-ranker/blob/main/languages/base/CPX.md") + Some("https://github.com/ffedoroff/code-ranker/blob/main/plugins/base/CPX.md") ); assert!( go.iter() @@ -434,3 +434,74 @@ fn rust_config_report_override_is_the_demo() { assert!(rust.columns.add.contains(&"unsafe".to_string())); assert!(rust.stats.add.contains(&"unsafe".to_string())); } + +/// Static regression guard: no file extension is claimed by two plugins in +/// their default (static) configs. This catches build-time authoring bugs +/// before they become runtime ambiguity errors; Rust has no `extensions` entry +/// (it uses `cargo metadata` for file discovery), which is also fine (empty +/// list → no contribution to the map). +#[test] +fn registry_extensions_are_unique_across_plugins() { + use std::collections::HashMap; + let mut ext_to_plugins: HashMap> = HashMap::new(); + for plugin in code_ranker_plugin_api::plugin::registry() { + let cfg = plugin.config(); + for ext in string_list(&cfg, "extensions") { + ext_to_plugins + .entry(ext) + .or_default() + .push(plugin.name().to_string()); + } + } + let conflicts: Vec = ext_to_plugins + .iter() + .filter(|(_, plugins)| plugins.len() > 1) + .map(|(ext, plugins)| format!(".{ext}: {:?}", plugins)) + .collect(); + assert!( + conflicts.is_empty(), + "extension(s) claimed by multiple plugins (built-in default configs): {}", + conflicts.join(", ") + ); +} + +/// Static regression guard for language **aliases** (`aliases = [...]`): an alias +/// must be unambiguous everywhere a language is named. So no alias may be claimed +/// by two plugins, collide with any plugin's canonical `name()`, or shadow a +/// reserved `[plugins]` key (`base` / `enabled`). +#[test] +fn registry_aliases_are_unique_and_reserved_safe() { + use std::collections::HashMap; + let reg = code_ranker_plugin_api::plugin::registry(); + let canonical: Vec = reg.iter().map(|p| p.name().to_string()).collect(); + + let mut alias_to_plugins: HashMap> = HashMap::new(); + for plugin in ® { + for alias in string_list(&plugin.config(), "aliases") { + assert!( + !["base", "enabled"].contains(&alias.as_str()), + "{}: alias {alias:?} shadows a reserved [plugins] key", + plugin.name() + ); + assert!( + !canonical.contains(&alias), + "{}: alias {alias:?} collides with a canonical language name", + plugin.name() + ); + alias_to_plugins + .entry(alias) + .or_default() + .push(plugin.name().to_string()); + } + } + let conflicts: Vec = alias_to_plugins + .iter() + .filter(|(_, plugins)| plugins.len() > 1) + .map(|(alias, plugins)| format!("{alias}: {plugins:?}")) + .collect(); + assert!( + conflicts.is_empty(), + "alias(es) claimed by multiple plugins: {}", + conflicts.join(", ") + ); +} diff --git a/crates/code-ranker-viewer/src/assets/app.js b/crates/code-ranker-viewer/src/assets/app.js index 3a3d7c23..e45554bc 100644 --- a/crates/code-ranker-viewer/src/assets/app.js +++ b/crates/code-ranker-viewer/src/assets/app.js @@ -10,7 +10,7 @@ document.addEventListener('DOMContentLoaded', async () => { window.BASELINE = readEmbeddedSnapshot('cs-baseline'); window.CURRENT = readEmbeddedSnapshot('cs-current'); - const EMPTY = { graphs: {} }; + const EMPTY = { languages: {} }; window.DIFF = computeDiff(window.BASELINE ?? window.CURRENT ?? EMPTY, window.CURRENT ?? window.BASELINE ?? EMPTY); window.CYCLES = computeCycles(window.BASELINE ?? window.CURRENT ?? EMPTY, window.CURRENT ?? window.BASELINE ?? EMPTY); window.META = computeMeta(window.BASELINE, window.CURRENT); @@ -34,8 +34,16 @@ document.addEventListener('DOMContentLoaded', async () => { if (!window._ntSelected) window._ntSelected = {}; window._ntSelected[epState.level] = new Set(epState.sel); } - // Build any extra level views (e.g. `functions`) + the level switcher BEFORE - // node-table setup, so the per-level tables are wired up by the loop below. + // Restore active language from the URL before building level sections; default + // to the biggest language (most nodes + edges) when the URL pins none. + const snap = window.CURRENT ?? window.BASELINE; + const urlLang = getNavParams().lang; + const fallbackLang = (typeof defaultLang === 'function' && snap) ? defaultLang(snap) : null; + const initialLang = urlLang || fallbackLang || null; + if (initialLang && typeof setLang === 'function') setLang(initialLang); + + // Build any extra level views (e.g. `functions`) + the level/language switchers + // BEFORE node-table setup, so the per-level tables are wired up by the loop below. updateFilesTab(); document.querySelectorAll('.view').forEach(sec => setupNodeTable(sec, sec.dataset.view)); setupSnapPopup(); @@ -78,13 +86,19 @@ document.addEventListener('DOMContentLoaded', async () => { // Restore state from URL (skipped on a fresh load — the default above stands), // then set the initial history entry. - const { level: urlLevel, node: urlNode, group: urlGroup, mode: urlMode, depth: urlDepth, tier: urlTier, stat: urlStat2 } = np; + const { lang: urlLang2, level: urlLevel, node: urlNode, group: urlGroup, mode: urlMode, depth: urlDepth, tier: urlTier, stat: urlStat2 } = np; + // Language was already applied above; only switch + rebuild if the URL lang + // differs from what was applied (e.g. lang param added after initial render). + if (urlLang2 && urlLang2 !== (typeof currentLang === 'function' ? currentLang() : null)) { + if (typeof switchToLang === 'function') switchToLang(urlLang2); + } if (urlLevel && urlLevel !== currentLevel()) switchToLevel(urlLevel); if (!freshLoad) applyViewState({ level: urlLevel, group: urlGroup, mode: urlMode, depth: urlDepth, tier: urlTier, stat: urlStat2 }, { rerender: !!(urlGroup || urlMode || urlDepth || urlTier) }); if (urlNode) openModalForNode(urlNode, urlLevel ?? currentLevel()); // Replace initial history state so popstate can restore it. + const activeLangInit = (typeof currentLang === 'function') ? currentLang() : null; history.replaceState( - { level: currentLevel(), node: urlNode ?? null, group: window.drillGroup ?? null, mode: window.nodeSizeMode ?? null, depth: window.navDepth?.() ?? 0, tier: window.tier || null, side: window.viewSide, stat: window.navStat?.() ?? null, panel: window._statsOpen ? 'stats' : null }, + { lang: activeLangInit, level: currentLevel(), node: urlNode ?? null, group: window.drillGroup ?? null, mode: window.nodeSizeMode ?? null, depth: window.navDepth?.() ?? 0, tier: window.tier || null, side: window.viewSide, stat: window.navStat?.() ?? null, panel: window._statsOpen ? 'stats' : null }, '', location.href ); @@ -99,7 +113,12 @@ document.addEventListener('DOMContentLoaded', async () => { const lvl = st.level; const nid = st.node; const side = st.side; + const lang = st.lang; if (window.CURRENT && (side === 'baseline' || side === 'current')) setViewSide(side); + // Restore language before level so level sections are built for the right language. + if (lang && lang !== (typeof currentLang === 'function' ? currentLang() : null)) { + if (typeof switchToLang === 'function') switchToLang(lang); + } if (lvl && lvl !== currentLevel()) switchToLevel(lvl); applyViewState({ level: lvl ?? currentLevel(), group: st.group, mode: st.mode, depth: st.depth, tier: st.tier, stat: st.stat }, { rerender: true }); if (nid) { diff --git a/crates/code-ranker-viewer/src/assets/base.css b/crates/code-ranker-viewer/src/assets/base.css index 1b74a6eb..6cbdcea6 100644 --- a/crates/code-ranker-viewer/src/assets/base.css +++ b/crates/code-ranker-viewer/src/assets/base.css @@ -30,6 +30,22 @@ header code { color: #1abc9c; } /* Big mode word, now a toggle button — "toggle" between the two controls (click / hotkey `t` to switch baseline⇄current), "view" after the single control in review (disabled). Sized to match the header title; flex `order` reseats it. */ +/* Language switcher (rust / ts / …) — a header dropdown, shown only for + multi-language reports. Styled to match the active snapshot control + (`.snap-group.snap-active`): same height/padding/radius and the teal + "active" highlight, since it marks the active language. */ +.lang-select { font: inherit; font-size: 13px; font-weight: 600; color: #fff; + background-color: rgba(26,188,156,.20); border: 1px solid #1abc9c; + border-radius: 6px; box-shadow: 0 0 0 1px rgba(26,188,156,.5) inset; + padding: 6px 28px 6px 11px; cursor: pointer; + appearance: none; -webkit-appearance: none; -moz-appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath fill='none' stroke='%23fff' stroke-width='1.5' d='M1 1l4 4 4-4'/%3E%3C/svg%3E"); + background-repeat: no-repeat; background-position: right 9px center; + transition: border-color .15s, box-shadow .15s; } +.lang-select[hidden] { display: none; } +.lang-select:hover { border-color: #1abc9c; box-shadow: 0 0 0 1px rgba(26,188,156,.8) inset; } +.lang-select:focus { outline: none; } + /* Level switcher (Files / Functions / …) — shown only for multi-level reports. */ .report-switch { display: flex; gap: 6px; padding: 10px 16px 0; align-items: flex-end; } .report-switch[hidden] { display: none; } @@ -42,7 +58,8 @@ header code { color: #1abc9c; } .meta-mode { font: inherit; font-size: 13px; color: #ecf0f1; white-space: nowrap; text-transform: lowercase; margin: 0 4px; order: 2; user-select: none; - background: #455a73; border: 0; border-radius: 4px; padding: 6px 14px; + background: #455a73; border: 0; border-radius: 4px; padding: 0 14px; + height: 30px; box-sizing: border-box; display: inline-flex; align-items: center; cursor: pointer; transition: background .15s; } .meta-mode:hover:not(:disabled) { background: #5d7593; } .meta-mode:disabled { cursor: default; background: #3a4a5e; color: #8ba6c0; } @@ -52,7 +69,8 @@ body.mode-review #meta-mode { order: 4; } /* review: place "view" after the co /* Snapshot controls (branch + commit): clicking the body switches the shown side; the ✎ pencil opens the detail/actions popup. No hover trigger. */ .snap-group { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; - border: 1px solid rgba(255,255,255,.18); border-radius: 6px; padding: 6px 11px; + border: 1px solid rgba(255,255,255,.18); border-radius: 6px; padding: 0 11px; + height: 30px; box-sizing: border-box; transition: border-color .15s, box-shadow .15s; } /* In review mode there is only one snapshot — clicking the body is a no-op. */ body.mode-review .snap-group { cursor: default; } diff --git a/crates/code-ranker-viewer/src/assets/diff.js b/crates/code-ranker-viewer/src/assets/diff.js index e5cd539f..adfd538b 100644 --- a/crates/code-ranker-viewer/src/assets/diff.js +++ b/crates/code-ranker-viewer/src/assets/diff.js @@ -1,48 +1,65 @@ // ── Diff ────────────────────────────────────────────────────────────────────── +// Compute per-language, per-level diffs. Result shape: { [lang]: { [level]: { nodes, edges } } }. +// Each side may be absent (a language only in one snapshot → all added or all removed). function computeDiff(baseline, current) { const result = {}; - for (const level of ['files']) { - const bg = (baseline.graphs || {})[level] || { nodes: [], edges: [] }; - const ag = (current.graphs || {})[level] || { nodes: [], edges: [] }; - - const bgMap = new Map(bg.nodes.filter(n => !isExternalNode(n, level)).map(n => [n.id, n])); - const agMap = new Map(ag.nodes.filter(n => !isExternalNode(n, level)).map(n => [n.id, n])); - - const nodeStatus = new Map(); - for (const id of agMap.keys()) nodeStatus.set(id, bgMap.has(id) ? 'unchanged' : 'added'); - for (const id of bgMap.keys()) if (!agMap.has(id)) nodeStatus.set(id, 'removed'); - - const edgeKey = e => `${e.source}\x00${e.target}\x00${e.kind}`; - const localEdges = edges => edges.filter(e => nodeStatus.has(e.source) && nodeStatus.has(e.target)); - - const bgEdgeMap = new Map(localEdges(bg.edges).map(e => [edgeKey(e), e])); - const agEdgeMap = new Map(localEdges(ag.edges).map(e => [edgeKey(e), e])); - - const edgeStatus = new Map(); - for (const key of agEdgeMap.keys()) edgeStatus.set(key, bgEdgeMap.has(key) ? 'unchanged' : 'added'); - for (const key of bgEdgeMap.keys()) if (!agEdgeMap.has(key)) edgeStatus.set(key, 'removed'); - - const nodes = []; - for (const [id, status] of nodeStatus) - nodes.push({ ...(status === 'removed' ? bgMap.get(id) : agMap.get(id)), status }); - - const edges = []; - for (const [key, status] of edgeStatus) - edges.push({ ...(status === 'removed' ? bgEdgeMap.get(key) : agEdgeMap.get(key)), status }); - - for (const e of edges) { - if (e.status === 'unchanged') continue; - if (nodeStatus.get(e.source) === 'unchanged') nodeStatus.set(e.source, 'affected'); - if (nodeStatus.get(e.target) === 'unchanged') nodeStatus.set(e.target, 'affected'); + // All languages from either side. + const allLangs = new Set([ + ...Object.keys((baseline || {}).languages || {}), + ...Object.keys((current || {}).languages || {}), + ]); + for (const lang of allLangs) { + const bLang = (baseline?.languages || {})[lang] || {}; + const aLang = (current?.languages || {})[lang] || {}; + // All levels from either side of this language. + const allLevels = new Set([ + ...Object.keys(bLang.graphs || {}), + ...Object.keys(aLang.graphs || {}), + ]); + result[lang] = {}; + for (const level of allLevels) { + const bg = (bLang.graphs || {})[level] || { nodes: [], edges: [] }; + const ag = (aLang.graphs || {})[level] || { nodes: [], edges: [] }; + + const bgMap = new Map(bg.nodes.filter(n => !isExternalNode(n, level)).map(n => [n.id, n])); + const agMap = new Map(ag.nodes.filter(n => !isExternalNode(n, level)).map(n => [n.id, n])); + + const nodeStatus = new Map(); + for (const id of agMap.keys()) nodeStatus.set(id, bgMap.has(id) ? 'unchanged' : 'added'); + for (const id of bgMap.keys()) if (!agMap.has(id)) nodeStatus.set(id, 'removed'); + + const edgeKey = e => `${e.source}\x00${e.target}\x00${e.kind}`; + const localEdges = edges => edges.filter(e => nodeStatus.has(e.source) && nodeStatus.has(e.target)); + + const bgEdgeMap = new Map(localEdges(bg.edges).map(e => [edgeKey(e), e])); + const agEdgeMap = new Map(localEdges(ag.edges).map(e => [edgeKey(e), e])); + + const edgeStatus = new Map(); + for (const key of agEdgeMap.keys()) edgeStatus.set(key, bgEdgeMap.has(key) ? 'unchanged' : 'added'); + for (const key of bgEdgeMap.keys()) if (!agEdgeMap.has(key)) edgeStatus.set(key, 'removed'); + + const nodes = []; + for (const [id, status] of nodeStatus) + nodes.push({ ...(status === 'removed' ? bgMap.get(id) : agMap.get(id)), status }); + + const edges = []; + for (const [key, status] of edgeStatus) + edges.push({ ...(status === 'removed' ? bgEdgeMap.get(key) : agEdgeMap.get(key)), status }); + + for (const e of edges) { + if (e.status === 'unchanged') continue; + if (nodeStatus.get(e.source) === 'unchanged') nodeStatus.set(e.source, 'affected'); + if (nodeStatus.get(e.target) === 'unchanged') nodeStatus.set(e.target, 'affected'); + } + nodes.forEach(n => { n.status = nodeStatus.get(n.id); }); + edges.forEach(e => { + if (e.status === 'unchanged' && + (nodeStatus.get(e.source) !== 'unchanged' || nodeStatus.get(e.target) !== 'unchanged')) + e.status = 'affected'; + }); + + result[lang][level] = { nodes, edges }; } - nodes.forEach(n => { n.status = nodeStatus.get(n.id); }); - edges.forEach(e => { - if (e.status === 'unchanged' && - (nodeStatus.get(e.source) !== 'unchanged' || nodeStatus.get(e.target) !== 'unchanged')) - e.status = 'affected'; - }); - - result[level] = { nodes, edges }; } return result; } @@ -62,36 +79,51 @@ function buildSCCOf(graph, level) { return { sccOf, sccCount: cycles.length }; } +// Compute per-language, per-level cycle membership. Result shape: +// { [lang]: { [level]: { nodeCycleStatus, edgeCycleStatus, cycleBaseline, cycleCurrent, cycleBoth } } }. function computeCycles(baseline, current) { const result = {}; - for (const level of ['files']) { - const bg = (baseline.graphs || {})[level] || { nodes: [], edges: [], cycles: [] }; - const ag = (current.graphs || {})[level] || { nodes: [], edges: [], cycles: [] }; - - const { sccOf: bgSCCOf } = buildSCCOf(bg, level); - const { sccOf: agSCCOf } = buildSCCOf(ag, level); - - const nodeCycleStatus = new Map(); - for (const id of new Set([...bgSCCOf.keys(), ...agSCCOf.keys()])) { - const b = bgSCCOf.has(id), a = agSCCOf.has(id); - nodeCycleStatus.set(id, b && a ? 'both' : b ? 'baseline-only' : 'current-only'); + const allLangs = new Set([ + ...Object.keys((baseline || {}).languages || {}), + ...Object.keys((current || {}).languages || {}), + ]); + for (const lang of allLangs) { + const bLang = (baseline?.languages || {})[lang] || {}; + const aLang = (current?.languages || {})[lang] || {}; + const allLevels = new Set([ + ...Object.keys(bLang.graphs || {}), + ...Object.keys(aLang.graphs || {}), + ]); + result[lang] = {}; + for (const level of allLevels) { + const bg = (bLang.graphs || {})[level] || { nodes: [], edges: [], cycles: [] }; + const ag = (aLang.graphs || {})[level] || { nodes: [], edges: [], cycles: [] }; + + const { sccOf: bgSCCOf } = buildSCCOf(bg, level); + const { sccOf: agSCCOf } = buildSCCOf(ag, level); + + const nodeCycleStatus = new Map(); + for (const id of new Set([...bgSCCOf.keys(), ...agSCCOf.keys()])) { + const b = bgSCCOf.has(id), a = agSCCOf.has(id); + nodeCycleStatus.set(id, b && a ? 'both' : b ? 'baseline-only' : 'current-only'); + } + + const edgeCycleStatus = (from, to) => { + const inB = bgSCCOf.has(from) && bgSCCOf.get(from) === bgSCCOf.get(to); + const inA = agSCCOf.has(from) && agSCCOf.get(from) === agSCCOf.get(to); + if (!inB && !inA) return 'none'; + return inB && inA ? 'both' : inB ? 'baseline-only' : 'current-only'; + }; + + let cycleBaseline = 0, cycleCurrent = 0, cycleBoth = 0; + for (const cs of nodeCycleStatus.values()) { + if (cs === 'baseline-only') cycleBaseline++; + else if (cs === 'current-only') cycleCurrent++; + else cycleBoth++; + } + + result[lang][level] = { nodeCycleStatus, edgeCycleStatus, cycleBaseline, cycleCurrent, cycleBoth }; } - - const edgeCycleStatus = (from, to) => { - const inB = bgSCCOf.has(from) && bgSCCOf.get(from) === bgSCCOf.get(to); - const inA = agSCCOf.has(from) && agSCCOf.get(from) === agSCCOf.get(to); - if (!inB && !inA) return 'none'; - return inB && inA ? 'both' : inB ? 'baseline-only' : 'current-only'; - }; - - let cycleBaseline = 0, cycleCurrent = 0, cycleBoth = 0; - for (const cs of nodeCycleStatus.values()) { - if (cs === 'baseline-only') cycleBaseline++; - else if (cs === 'current-only') cycleCurrent++; - else cycleBoth++; - } - - result[level] = { nodeCycleStatus, edgeCycleStatus, cycleBaseline, cycleCurrent, cycleBoth }; } return result; } diff --git a/crates/code-ranker-viewer/src/assets/export-popup.js b/crates/code-ranker-viewer/src/assets/export-popup.js index 563887fd..410b472a 100644 --- a/crates/code-ranker-viewer/src/assets/export-popup.js +++ b/crates/code-ranker-viewer/src/assets/export-popup.js @@ -3,7 +3,8 @@ // a dependency cycle). Shown next to the Prompt-Generator (AI) button. function warningTypeCount(level) { // Count over the active side, so the badge tracks Baseline/Current like the rest. - const nodes = ((typeof activeGraph === 'function' ? activeGraph(level).nodes : window.DIFF?.[level]?.nodes) || []) + const _wLang = (typeof currentLang === 'function' ? currentLang() : null) || Object.keys(window.DIFF || {})[0]; + const nodes = ((typeof activeGraph === 'function' ? activeGraph(level).nodes : window.DIFF?.[_wLang]?.[level]?.nodes) || []) .filter(n => !isExternalNode(n, level)); const sortMetrics = levelUi(level).sort || []; let count = sortMetrics.filter(m => { @@ -12,7 +13,11 @@ function warningTypeCount(level) { if (!th) return false; return nodes.some(n => (nodeAttr(n, m) ?? 0) > th.warning); }).length; - const cy = window.CYCLES?.[level]?.nodeCycleStatus; + // CYCLES shape is { [lang]: { [level]: { nodeCycleStatus, … } } } — resolve via + // the active language before indexing by level. + const activeLang = (typeof currentLang === 'function') ? currentLang() : null; + const langCycles = activeLang ? window.CYCLES?.[activeLang] : Object.values(window.CYCLES || {})[0]; + const cy = langCycles?.[level]?.nodeCycleStatus; if (cy && nodes.some(n => { const cs = cy.get(n.id); return cs != null && cs !== 'none'; })) count += 1; return count; } @@ -73,7 +78,8 @@ function openExportPopup(level, restore) { // the snapshot the user is looking at — same source the map and table use. // (Review mode → the single snapshot.) Edges are kept to local↔local pairs, // mirroring the diff's edge set: no external links, no cross-side noise. - const activeG = (typeof activeGraph === 'function') ? activeGraph(level) : (window.DIFF?.[level] || {}); + const _oLang = (typeof currentLang === 'function' ? currentLang() : null) || Object.keys(window.DIFF || {})[0]; + const activeG = (typeof activeGraph === 'function') ? activeGraph(level) : (window.DIFF?.[_oLang]?.[level] || {}); const allNodes = activeG.nodes || []; const localIds = new Set(allNodes.filter(n => !isExternalNode(n, level)).map(n => n.id)); // Only FLOW edges (`uses`) drive coupling/cycles/HK; structural @@ -197,7 +203,10 @@ function openExportPopup(level, restore) { // `cycle` → only nodes in a cycle (sorted by hk). const recoFor = metric => { if (metric === 'cycle') { - const cy = window.CYCLES?.[level]; + // CYCLES shape is { [lang]: { [level]: … } } — resolve active language first. + const activeLang = (typeof currentLang === 'function') ? currentLang() : null; + const langCycles = activeLang ? window.CYCLES?.[activeLang] : Object.values(window.CYCLES || {})[0]; + const cy = langCycles?.[level]; const inCycle = internalNodes().filter(n => cy?.nodeCycleStatus?.get(n.id) != null) .sort((a, b) => (nodeAttr(b, 'hk') ?? 0) - (nodeAttr(a, 'hk') ?? 0)); return { metric: 'cycle', sorted: inCycle, warningCount: inCycle.length, infoCount: inCycle.length }; diff --git a/crates/code-ranker-viewer/src/assets/index.html b/crates/code-ranker-viewer/src/assets/index.html index 9680feb9..7813c93a 100644 --- a/crates/code-ranker-viewer/src/assets/index.html +++ b/crates/code-ranker-viewer/src/assets/index.html @@ -14,6 +14,10 @@
code-ranker__CR_VERSION__ + +
diff --git a/crates/code-ranker-viewer/src/assets/layout.js b/crates/code-ranker-viewer/src/assets/layout.js index 7968e1f5..1a427499 100644 --- a/crates/code-ranker-viewer/src/assets/layout.js +++ b/crates/code-ranker-viewer/src/assets/layout.js @@ -71,7 +71,11 @@ function buildDOT(nodes, edges, level, viewport) { // by the zoom that was active when the user drilled in. const activeDig = drillGroup === null ? (window.dig || 0) : (window.drillDig ?? 0); const gOf = grouperForDig(level, activeDig); - const cycleOf = window.CYCLES?.[level]?.nodeCycleStatus; + // CYCLES is keyed [lang][level]; resolve active language before indexing. + const _langForCycles = (typeof currentLang === 'function' ? currentLang() : null) + || Object.keys(window.CYCLES || {})[0]; + const _langCycles = _langForCycles ? window.CYCLES?.[_langForCycles] : null; + const cycleOf = _langCycles?.[level]?.nodeCycleStatus; // Node filter (data-driven from `ui.filter`): when a key is active keep // only nodes where that metric has signal. `cycle` is special (uses the cycle // membership set); any other key keeps nodes whose attribute value is non-zero. @@ -292,7 +296,7 @@ function buildDOT(nodes, edges, level, viewport) { return Math.max(db, da) || metricNodeDiam(n, sizeMode); }; - const edgeCycleOf = window.CYCLES?.[level]?.edgeCycleStatus; + const edgeCycleOf = _langCycles?.[level]?.edgeCycleStatus; // Non-flow edges (contains / reexports) render DASHED and tagged `edge-nonflow` // so CSS keeps them hidden until a node hover reveals the connected ones; flow // edges stay solid and always visible. diff --git a/crates/code-ranker-viewer/src/assets/map-interactions.js b/crates/code-ranker-viewer/src/assets/map-interactions.js index 143ceb2a..d6659592 100644 --- a/crates/code-ranker-viewer/src/assets/map-interactions.js +++ b/crates/code-ranker-viewer/src/assets/map-interactions.js @@ -721,7 +721,10 @@ function statusLineFor(node, level) { // the figures the status bar shows for a crate/group box, and for the external // caller/dependency neighbour boxes in the drilled view. function computeGroupStats(level, grouper) { - const cyc = window.CYCLES?.[level]?.nodeCycleStatus; + // CYCLES is keyed [lang][level]; resolve the active language first. + const _lang = (typeof currentLang === 'function' ? currentLang() : null) + || Object.keys(window.CYCLES || {})[0]; + const cyc = (_lang ? window.CYCLES?.[_lang] : null)?.[level]?.nodeCycleStatus; const stats = new Map(); for (const n of unionGraph(level).nodes) { const grp = grouper(n); diff --git a/crates/code-ranker-viewer/src/assets/modal-content.js b/crates/code-ranker-viewer/src/assets/modal-content.js index 87169dc1..d3556244 100644 --- a/crates/code-ranker-viewer/src/assets/modal-content.js +++ b/crates/code-ranker-viewer/src/assets/modal-content.js @@ -33,7 +33,10 @@ function nodeCrumbsHtml(node, level) { } function buildModalContent(node, level) { - const cycles = window.CYCLES?.[level]; + // CYCLES is keyed [lang][level]; resolve the active language first. + const _lang = (typeof currentLang === 'function' ? currentLang() : null) + || Object.keys(window.CYCLES || {})[0]; + const cycles = (_lang ? window.CYCLES?.[_lang] : null)?.[level]; const cs = cycles?.nodeCycleStatus?.get(node.id); const mnExt = isExternalNode(node, level); // Displayed path: external keeps its compact `{registry}`/`{cargo}` token diff --git a/crates/code-ranker-viewer/src/assets/modal.js b/crates/code-ranker-viewer/src/assets/modal.js index 434e738f..22952251 100644 --- a/crates/code-ranker-viewer/src/assets/modal.js +++ b/crates/code-ranker-viewer/src/assets/modal.js @@ -113,8 +113,9 @@ function getModal() { // breadcrumb in the new representation. if (opt.dataset.tier !== window.viewTier?.(level)) window.switchTier?.(opt.dataset.tier, level); const m = window._modalNode; + const _mLang = (typeof currentLang === 'function' ? currentLang() : null) || Object.keys(window.DIFF || {})[0]; const node = m && ((typeof activeGraph === 'function' ? activeGraph(m.level).nodes : []).find(n => n.id === m.id) - ?? window.DIFF?.[m.level]?.nodes?.find(n => n.id === m.id)); + ?? window.DIFF?.[_mLang]?.[m.level]?.nodes?.find(n => n.id === m.id)); if (node) document.getElementById('node-modal-hdr-title').innerHTML = window.nodeHeaderHtml(node, m.level); document.querySelectorAll('.tier-menu:not([hidden])').forEach(mn => mn.setAttribute('hidden', '')); return; @@ -234,8 +235,9 @@ function closeModal() { // unchanged (same folder, even if a different file — or the same file) → leave // the map exactly where it was, just drop the node. if (m && openId && level && level === activeLevel && window.drillIntoGroup) { + const _cLang = (typeof currentLang === 'function' ? currentLang() : null) || Object.keys(window.DIFF || {})[0]; const lookup = id => (typeof activeGraph === 'function' ? activeGraph(level).nodes : []).find(n => n.id === id) - ?? window.DIFF?.[level]?.nodes?.find(n => n.id === id); + ?? window.DIFF?.[_cLang]?.[level]?.nodes?.find(n => n.id === id); const closeTgt = (n => n ? focusFolderTarget(level, n) : null)(lookup(m.id)); const openTgt = (n => n ? focusFolderTarget(level, n) : null)(lookup(openId)); if (closeTgt && closeTgt.key && closeTgt.key !== '_root' diff --git a/crates/code-ranker-viewer/src/assets/nav.js b/crates/code-ranker-viewer/src/assets/nav.js index a9def99c..a27a9c2e 100644 --- a/crates/code-ranker-viewer/src/assets/nav.js +++ b/crates/code-ranker-viewer/src/assets/nav.js @@ -1,6 +1,7 @@ function getNavParams() { const p = new URLSearchParams(location.search); return { + lang: p.get('lang'), level: p.get('level'), node: p.get('node'), side: p.get('side'), @@ -36,7 +37,13 @@ function navDepth() { } function navViewState() { + const snap = window.CURRENT ?? window.BASELINE; + const langs = (typeof langKeys === 'function' && snap) ? langKeys(snap) : []; + // Omit `lang` when there is only one language — keeps URLs clean for existing + // single-language reports. + const langVal = langs.length > 1 ? ((typeof currentLang === 'function' ? currentLang() : null) || langs[0]) : null; return { + lang: langVal, level: currentLevel() ?? null, side: navSide(), group: window.drillGroup || null, @@ -49,6 +56,7 @@ function navViewState() { } function navViewUrl(st) { const p = new URLSearchParams(); + if (st.lang) p.set('lang', st.lang); if (st.level) p.set('level', st.level); if (st.side) p.set('side', st.side); if (st.group) p.set('group', st.group); @@ -106,10 +114,26 @@ function switchToLevel(target) { const sec = document.querySelector('.view.active'); if (sec && sec.dataset.rendered !== 'true' && window.gv) renderView(sec); } +// Switch the active language and rebuild the level sections + switchers for it. +// Persists `lang` in the URL; uses replaceState (no extra history entry). +function switchToLang(target) { + if (typeof setLang === 'function') setLang(target); + // Reflect the active language in the header dropdown. + const langSel = document.getElementById('lang-switch'); + if (langSel) langSel.value = target; + // Rebuild level sections / diff / cycles for the new language. + if (typeof updateFilesTab === 'function') updateFilesTab(); + if (typeof recomputeAll === 'function') recomputeAll(); + // Persist language in the URL. + const st = navViewState(); + history.replaceState(st, '', navViewUrl(st)); +} function openModalForNode(nodeId, level) { // Is the node on the side currently shown? (vs. only in the union/DIFF) const onSide = activeGraph(level).nodes.find(n => n.id === nodeId); - const nodeData = onSide ?? window.DIFF?.[level]?.nodes?.find(n => n.id === nodeId); + // DIFF is keyed [lang][level]; resolve the active language. + const _nLang = (typeof currentLang === 'function' ? currentLang() : null) || Object.keys(window.DIFF || {})[0]; + const nodeData = onSide ?? window.DIFF?.[_nLang]?.[level]?.nodes?.find(n => n.id === nodeId); if (!nodeData) return false; // Remember which node the modal shows so a baseline⇄current toggle can re-render it. // On a FRESH open (the overlay is not already visible) also remember it as the diff --git a/crates/code-ranker-viewer/src/assets/node-popup.js b/crates/code-ranker-viewer/src/assets/node-popup.js index 93f0606c..1b0a2978 100644 --- a/crates/code-ranker-viewer/src/assets/node-popup.js +++ b/crates/code-ranker-viewer/src/assets/node-popup.js @@ -5,7 +5,9 @@ function buildDiagramSVG(node, level) { // Nodes that are selected on the main map get the same yellow highlight here. const selectedIds = window._ntSelected?.[level]; - const diff = window.DIFF?.[level]; + // DIFF is keyed [lang][level]; resolve active language before indexing. + const _dLang = (typeof currentLang === 'function' ? currentLang() : null) || Object.keys(window.DIFF || {})[0]; + const diff = _dLang ? window.DIFF?.[_dLang]?.[level] : null; // Use the ACTIVE side's raw snapshot (externals included, unlike DIFF). Tying // this to the shown side keeps the popup in-status: viewing the baseline shows // only baseline neighbours (no added/current-only nodes), and viewing current @@ -191,8 +193,10 @@ function buildDiagramSVG(node, level) { }); const VH = cursor + MARG; - // Cycle highlight state - const cycleNodes = window.CYCLES?.[level]?.nodeCycleStatus; + // Cycle highlight state. CYCLES is keyed [lang][level]; resolve active language. + const _popLang = (typeof currentLang === 'function' ? currentLang() : null) + || Object.keys(window.CYCLES || {})[0]; + const cycleNodes = (_popLang ? window.CYCLES?.[_popLang] : null)?.[level]?.nodeCycleStatus; const isCycleNode = id => { const cs = cycleNodes?.get(id); if (cs == null || cs === 'none') return false; diff --git a/crates/code-ranker-viewer/src/assets/node-table.js b/crates/code-ranker-viewer/src/assets/node-table.js index 7abc3475..b828f51e 100644 --- a/crates/code-ranker-viewer/src/assets/node-table.js +++ b/crates/code-ranker-viewer/src/assets/node-table.js @@ -72,7 +72,10 @@ function setupNodeTable(section, level) { const groupKey = () => levelUi(level).grouping?.key || null; function buildAggregates(files) { const numCols = cols.filter(c => c.isNum).map(c => c.id); - const cyc = window.CYCLES?.[level]?.nodeCycleStatus; // id → cycle status (cycle members only) + // CYCLES is keyed [lang][level]; resolve active language before indexing. + const _cycLang = (typeof currentLang === 'function' ? currentLang() : null) + || Object.keys(window.CYCLES || {})[0]; + const cyc = (_cycLang ? window.CYCLES?.[_cycLang] : null)?.[level]?.nodeCycleStatus; // id → cycle status (cycle members only) const mk = (id, label, kind, cat, members, extra) => { const n = { id, name: label, kind, _cat: cat, _count: members.length, ...extra }; for (const key of numCols) { diff --git a/crates/code-ranker-viewer/src/assets/schema.js b/crates/code-ranker-viewer/src/assets/schema.js index 846b3b4d..dda75063 100644 --- a/crates/code-ranker-viewer/src/assets/schema.js +++ b/crates/code-ranker-viewer/src/assets/schema.js @@ -6,13 +6,18 @@ // attribute_groups / node_kinds / cycle_kinds / ui) and the top-level // `principles`. No metric/kind is hardcoded by name anywhere in the frontend. -// The level's dictionaries (specs) — read from the active snapshot, which is the -// authority for how to render. Falls back to the other side so a single-snapshot -// report works. +// The level's dictionaries (specs) — read from the active snapshot's language +// sub-object, which is the authority for how to render. Falls back to the other +// side so a single-snapshot report works. function specSnap() { - return (window.viewSide === 'baseline') + const snap = (window.viewSide === 'baseline') ? (window.BASELINE ?? window.CURRENT) : (window.CURRENT ?? window.BASELINE); + if (!snap) return null; + const lang = (typeof currentLang === 'function' ? currentLang() : null) + || (typeof langKeys === 'function' ? langKeys(snap)[0] : null) + || Object.keys(snap.languages || {})[0]; + return lang ? (snap.languages?.[lang] ?? null) : null; } function levelSpec(level) { return specSnap()?.graphs?.[level] || {}; diff --git a/crates/code-ranker-viewer/src/assets/snap-controls.js b/crates/code-ranker-viewer/src/assets/snap-controls.js index bb7c55a9..c429b93a 100644 --- a/crates/code-ranker-viewer/src/assets/snap-controls.js +++ b/crates/code-ranker-viewer/src/assets/snap-controls.js @@ -98,39 +98,79 @@ function updateHeader() { } -// Build the per-level views + the level switcher from the loaded snapshot's graph -// levels. The `files` view is the static template in index.html; every other level -// (e.g. `functions`, emitted when `[levels] functions = true`) is cloned from it — -// rendering is level-agnostic (renderView / setupNodeTable scope to the section + -// its `data-view`). The switcher row is shown only when more than one level is -// present, so single-level reports look exactly as before. Idempotent: re-running -// (on a snapshot swap) skips existing sections and just refreshes the switcher. +// Build the per-level views, the level switcher, and the language switcher from +// the loaded snapshot. The `files` view is the static template in index.html; +// every other level (e.g. `functions`) is cloned from it. The language switcher +// appears above the level switcher and is hidden when only one language exists. +// On language change the non-files level sections are torn down and rebuilt for +// the new language (id-namespaced to avoid collisions). function updateFilesTab() { - const snap = window.CURRENT ?? window.BASELINE ?? { graphs: {} }; - const levels = Object.keys(snap.graphs || {}); - if (!levels.length) return; - // Stable order: files first, then the rest in snapshot order. + const snap = window.CURRENT ?? window.BASELINE; + if (!snap) return; + + // Determine the active language; default to the first key. + const langs = (typeof langKeys === 'function') ? langKeys(snap) : Object.keys(snap.languages || {}); + if (!langs.length) return; + const activeLang = (typeof currentLang === 'function' && currentLang()) + || (typeof defaultLang === 'function' ? defaultLang(snap) : langs[0]); + if (typeof setLang === 'function') setLang(activeLang); + + const langSnap = (snap.languages || {})[activeLang] || {}; + const levels = Object.keys(langSnap.graphs || {}); + if (!levels.length) levels.push('files'); + // Stable order: files first. levels.sort((a, b) => (a === 'files' ? -1 : b === 'files' ? 1 : 0)); const main = document.querySelector('main'); const template = document.querySelector('.view[data-view="files"]'); if (!main || !template) return; - // One section per extra level: clone the files template for any level missing a - // view. The clone is built clean (this runs before node-table setup), with its - // per-level element ids made unique (`*-files` → `*-`). + // Tear down previously cloned non-files level sections (from a prior language + // or a prior snapshot) so we rebuild them fresh for the active language. + main.querySelectorAll('.view[data-view]:not([data-view="files"])').forEach(sec => sec.remove()); + + // One section per extra level: clone the files template with ids namespaced by + // both language and level to avoid collisions (lang-level suffix). for (const level of levels) { - if (level === 'files' || document.querySelector(`.view[data-view="${level}"]`)) continue; + if (level === 'files') continue; const sec = template.cloneNode(true); sec.dataset.view = level; sec.classList.remove('active'); delete sec.dataset.rendered; sec.querySelectorAll('.svg-frame').forEach(f => { f.innerHTML = ''; }); - sec.querySelectorAll('[id]').forEach(el => { el.id = el.id.replace(/files$/, level); }); + // Namespace ids: `*-files` → `*--` (unique across language+level). + const suffix = `${activeLang}-${level}`; + sec.querySelectorAll('[id]').forEach(el => { el.id = el.id.replace(/files$/, suffix); }); main.appendChild(sec); } - // Level switcher: one tab per level, wired to switchToLevel + URL persistence. + // ── Language switcher (header dropdown) ──────────────────────────────────── + // A