From 4216524106d51154ca1917d870126c4be6f713f5 Mon Sep 17 00:00:00 2001 From: Roman Fedorov Date: Sat, 27 Jun 2026 15:45:43 +0300 Subject: [PATCH 01/10] docs: rewrite for v5.0 multi-language format Describe the multi-language model as the only reality (no back-compat): - config: plugins = [...] array, [languages.] per-language override of any plugin key, virtual [languages.base] base layer; version 5.0 - CLI: --plugins, --language, --config languages..=value; docs keeps singular --plugin - plugin resolution: auto-detect < config < console; auto-detect all matching languages, no ambiguity error; zero-detect / extension-conflict errors - snapshot schema 5.0: plugins[] + languages..{graphs,principles,prompt}; Violation gains language field - viewer: language switcher in header; reads languages. - contrib: prompt-eval-metrics.py reads languages..graphs; unit-tests.md uses resolve_plugins/detect_all/check_violations_all --- contrib/prompt-eval-metrics.py | 13 ++- contrib/unit-tests.md | 21 ++-- docs/DESIGN.md | 144 +++++++++++++----------- docs/PRD.md | 61 +++++----- docs/ai-skill.md | 14 ++- docs/code-ranker-cli/CLI.md | 143 +++++++++++++++-------- docs/code-ranker-cli/DESIGN.md | 50 ++++---- docs/code-ranker-cli/ERRORS.md | 21 +++- docs/code-ranker-cli/PRD.md | 61 ++++++---- docs/code-ranker-cli/USE-CASES.md | 22 +++- docs/code-ranker-cli/config.md | 142 ++++++++++++++++++----- docs/code-ranker-viewer/DESIGN.md | 32 +++--- docs/code-ranker-viewer/PRD.md | 7 +- docs/customization/README.md | 17 ++- docs/customization/cel-reference.md | 8 ++ docs/customization/config-resolution.md | 43 ++++++- docs/e2e.md | 8 +- docs/node_schema.md | 13 +-- docs/templates.md | 6 +- docs/versions.md | 26 ++--- 20 files changed, 565 insertions(+), 287 deletions(-) diff --git a/contrib/prompt-eval-metrics.py b/contrib/prompt-eval-metrics.py index a5185add..e28cd45c 100755 --- a/contrib/prompt-eval-metrics.py +++ b/contrib/prompt-eval-metrics.py @@ -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/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/docs/DESIGN.md b/docs/DESIGN.md index c367fd9b..769cd37f 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -154,7 +154,7 @@ flowchart TD - [x] `p1` - **ID**: `cpt-code-ranker-principle-json-contract` -The single JSON snapshot (one `files` graph plus metadata) is the +The single JSON snapshot (per-language `files` graphs plus metadata) is the ONLY handoff between the plugin layer and the consumer layer. No in-process coupling between the analysis crates and the report rendering code is permitted. This contract is versioned via @@ -282,12 +282,12 @@ keys it understands, described per level by the semantics dictionaries. | AttributeSpec | Everything the UI needs to render a metric from data: `value_type`, `label`, `name` (tooltip title), `short` (table header), `description` (the diagnostic *why*), `remediation` (the diagnostic *fix* — both shown by `check`, data not Rust), `formula` (display), `calc` (an `eval`-able JS expression over sibling attrs — the live derivation), `direction` (`higher_better`/`lower_better`, for delta colour; **absent → the Δ stays neutral / uncoloured** — used for raw sizes like `sloc`/`lloc`/`blank` and for `fan_in`/`fan_out` (high coupling is dual — a tangled unit or a legitimate coordinator — so the directional signal lives in `hk` only), which have no agreed "good" way to move), `abbreviate` (K/M — **viewer-only**: the CLI scorecard/prompt always print exact integers), `group`, `thresholds {info, warning}`. All optional but `value_type`. | `crates/code-ranker-plugin-api/src/level.rs` | | NodeKindSpec / CycleKindSpec | Per-kind UI semantics. `NodeKindSpec`: `label`/`plural`/`fill`/`stroke`/`external`. `CycleKindSpec`: `label`/`description` (the cycle *why*)/`remediation` (the *fix*). `default_node_kinds()` seeds node kinds; `default_cycle_kinds()` seeds only the cycle *keys* (`mutual`/`chain`) — the orchestrator overlays the vocabulary centrally from `code-ranker-graph`'s `cycle_specs()` (the `builtin.toml [cycles.*]` catalog), so no cycle prose lives in Rust. | `crates/code-ranker-plugin-api/src/level.rs` | | Thresholds | `{ info: f64, warning: f64 }` — two-tier per-metric advisory thresholds overlaid onto the matching `AttributeSpec`; `warning` is the `[rules.thresholds.file]` gate limit, `info` an optional softer line below it (so the report mirrors the gate). | `crates/code-ranker-plugin-api/src/level.rs` | -| Principle | A Prompt-Generator principle: `id`, `label`, `title`, `prompt`, `doc_url?`, `sort_metric`, `connections`. The orchestrator builds a generic default catalog (`code-ranker-cli/src/principles.rs`) and a plugin's `principles(input)` hook may pass through / edit / extend it. Stored top-level in the snapshot. Prompt-generator domain data — lives in its own module, not the parser contract. | `crates/code-ranker-plugin-api/src/principle.rs` | -| PromptTemplate | The language-neutral prompt **scaffolding** the Prompt-Generator wraps a `Principle` in: `intro`, `doc_note`, `task` (bullet lines), `focus`, `cycle_note` (`{id}` substituted at render). Data, not code — sourced from `code-ranker-graph/metrics/prompt.md` (`code-ranker-graph`'s `prompt_template()`), carried top-level in the snapshot so the CLI `prompt` format and the viewer's Prompt Generator render the same text from one source. | `crates/code-ranker-plugin-api/src/principle.rs` | +| Principle | A Prompt-Generator principle: `id`, `label`, `title`, `prompt`, `doc_url?`, `sort_metric`, `connections`. The orchestrator builds a generic default catalog (`code-ranker-cli/src/principles.rs`) and a plugin's `principles(input)` hook may pass through / edit / extend it. Stored per language in the snapshot (`languages..principles`). Prompt-generator domain data — lives in its own module, not the parser contract. | `crates/code-ranker-plugin-api/src/principle.rs` | +| PromptTemplate | The language-neutral prompt **scaffolding** the Prompt-Generator wraps a `Principle` in: `intro`, `doc_note`, `task` (bullet lines), `focus`, `cycle_note` (`{id}` substituted at render). Data, not code — sourced from `code-ranker-graph/metrics/prompt.md` (`code-ranker-graph`'s `prompt_template()`), carried per language in the snapshot (`languages..prompt`) so the CLI `prompt` format and the viewer's Prompt Generator render the same text from one source. | `crates/code-ranker-plugin-api/src/principle.rs` | | CycleGroup | SCC with ≥ 2 nodes: `kind: String` (`"mutual"` for a 2-node SCC, `"chain"` for 3+), `nodes: Vec`. Each member node also carries a `cycle` attribute. | `crates/code-ranker-graph/src/level_graph.rs` | | LevelUi | Computed UI hints: `default_sort`, `sort`, `size`, `card`, `columns`, `summary` — each a curated metric order filtered to the attributes present on internal nodes, so the viewer renders them verbatim and hardcodes none of it — plus an optional `grouping` (carried through from the level spec, pruned to a usable attribute) telling the viewer how to cluster diagram nodes. | `crates/code-ranker-graph/src/level_graph.rs` | | LevelGraph | One analysis level in the snapshot: the semantics dictionaries (`edge_kinds`/`node_attributes`/`edge_attributes`/`attribute_groups`/`node_kinds`/`cycle_kinds`) + `nodes` + `edges` + `cycles: Vec` + `stats: BTreeMap` (flat averages) + `ui: LevelUi`. | `crates/code-ranker-graph/src/level_graph.rs` | -| Snapshot | The `.json` artifact: `schema_version: "4.0"`, `generated_at`, `command`, `workspace`, `target`, `plugin`, `config_file?`, `versions`, `roots`, `git?`, `timings`, `graphs: BTreeMap`, top-level `principles: Vec`, and `prompt: PromptTemplate` (the Prompt-Generator scaffolding prose, read by both the CLI and the viewer). Serialized via `to_canonical_string_pretty` — **canonical JSON** (alphabetical keys; `nodes`/`edges` sorted). | `crates/code-ranker-graph/src/snapshot.rs` | +| Snapshot | The `.json` artifact: `schema_version: "5.0"`, `generated_at`, `command`, `workspace`, `target`, `plugins: Vec`, `config_file?`, `versions`, `roots`, `git?`, `timings`, and `languages: BTreeMap` — one entry per analyzed language, each carrying `graphs: BTreeMap`, `principles: Vec`, and `prompt: PromptTemplate` (the Prompt-Generator scaffolding prose, read by both the CLI and the viewer). Serialized via `to_canonical_string_pretty` — **canonical JSON** (alphabetical keys; `nodes`/`edges` sorted). | `crates/code-ranker-graph/src/snapshot.rs` | | StageTime | Per-stage timing entry: `stage`, `ms`, `detail`. Stored in `Snapshot.timings` in execution order. | `crates/code-ranker-graph/src/snapshot.rs` | **Relationships**: @@ -341,7 +341,8 @@ Modules: - **`finalize.rs`** — `finalize_graph`: drop self-loops, dedup edges on `(source, target, kind)`, prune unreferenced external nodes, sort. - **`snapshot.rs`** — the top-level `Snapshot` artifact (`schema_version`, - header, `graphs` map, `principles`) plus its header types `GitInfo` / `StageTime`. + header, `languages` map — each a `LanguageSnapshot` of `graphs` + `principles` + + `prompt`) plus its header types `GitInfo` / `StageTime`. - **`level_graph.rs`** — the widely-imported per-level payload types: `LevelGraph` (graph + semantics dictionaries + computed cycles/stats/UI), `LevelUi`, and `CycleGroup`. Split out from `snapshot.rs` so their fan-in lands here, not on @@ -455,11 +456,12 @@ language. Plugins **self-register**: each module in `code-ranker-plugins` submit itself with `inventory::submit! { PluginRegistration(&XPlugin) }`, and the binary collects them via `code_ranker_plugin_api::registry() -> Vec<&'static dyn LanguagePlugin>` (the `inventory` crate's distributed slice). Dispatch (`analyze`), -`detect`, `resolve_plugin`, and `versions` all iterate that array. Adding a language +`detect`, `resolve_plugins`, and `versions` all iterate that array. Adding a language is: implement the trait in a module and add one `inventory::submit!` — **no central list to edit** anywhere (the CLI, `plugin::registry()`, and `lib.rs` are untouched). -Link order is not significant: auto-detect errors on multiple matches rather than -picking by position. A `LanguagePlugin::config() -> toml::Table` accessor surfaces a +Link order is not significant: `resolve_plugins` runs **every** plugin whose +`detect()` matches — multiple matches is the normal multi-language case, not an +error. A `LanguagePlugin::config() -> toml::Table` accessor surfaces a plugin's fully-merged config for `--export-full-config`. #### code-ranker-plugins · rust module @@ -753,7 +755,7 @@ See [§3.7 Plugin System](#37-plugin-system). - **Location**: defined by `Snapshot`, `Node`, `Edge` structs in `crates/code-ranker-graph/src/` -- **Versioning**: `schema_version: "4.0"`; additive fields are minor; +- **Versioning**: `schema_version: "5.0"`; additive fields are minor; breaking changes require a major-version bump ### 3.4 Internal Dependencies @@ -769,8 +771,8 @@ See [§3.7 Plugin System](#37-plugin-system). | `code-ranker-plugins` (`javascript`/`typescript` modules) | `code-ranker-plugins` (`ecmascript` module) | shared ECMAScript walker/resolver + ECMAScript `Dialect` + `ecmascript_level` + `ecmascript_metrics`; each injects its own grammar. **Neither plugin depends on the other.** | | `code-ranker-graph` | `code-ranker-plugin-api` | the generic model it operates on; the metric scaffolding (modules `metrics` + `builtin`) builds `MetricInputs`/`metric_specs` on its `AttributeSpec` / `AttrValue` types | | `code-ranker-viewer` | `code-ranker-graph` | `Snapshot`, `to_canonical_string` | -| `code-ranker-cli` (`run_report`) | the analyzed snapshot (+ optional `--baseline`) | top-level metadata + `graphs` map; rendered via `code-ranker-viewer` | -| `code-ranker-cli` (`run_check`) | the analyzed snapshot | `graphs` map; per-rule violation evaluation (relative gate re-evaluates the baseline's rules) | +| `code-ranker-cli` (`run_report`) | the analyzed snapshot (+ optional `--baseline`) | top-level metadata + the `languages..graphs` maps; rendered via `code-ranker-viewer` | +| `code-ranker-cli` (`run_check`) | the analyzed snapshot | the `languages..graphs` maps; per-rule violation evaluation (relative gate re-evaluates the baseline's rules) | **Rules**: @@ -855,7 +857,7 @@ sequenceDiagram participant G as code-ranker-graph (graph ops + metric scaffolding) participant FS as Filesystem - User ->> CLI: code-ranker report . --plugin rust --output.json + User ->> CLI: code-ranker report . --plugins rust --output.json CLI ->> Plugin: analyze(ws, "files", input) (input.ignore_tests → plugin drops test files) Note over Plugin: syn + cargo metadata → collapse to files (STRUCTURE ONLY) Plugin -->> CLI: api::Graph (abs-path file ids, ext:* nodes) + Level specs @@ -934,34 +936,43 @@ sequenceDiagram #### Plugin Resolution All plugins are built into the `code-ranker` binary; there is no external -or dynamic plugin loading. Resolution only selects which built-in plugin -to run. +or dynamic plugin loading. Resolution selects the **set** of built-in plugins +to run — a run analyzes every active language and produces one report covering +all of them. -The plugin defaults to `auto`. When `--plugin auto`, the analysis core -(behind `check` / `report`) resolves the plugin *name* in this order, -stopping at the first match: +`resolve_plugins` produces the active set via three levels, each fully +**replacing** the lower (no merge), highest wins: ``` -1. Explicit flag --plugin (≠ auto) on the command line - → use that built-in plugin - -2. Config the `plugin` key in code-ranker.toml / - Cargo.toml metadata (if set and ≠ auto) - → use that built-in plugin - -3. Auto-detect each plugin's `detect(ws, input)` over `registry()`: - Cargo.toml → rust; - pyproject.toml / setup.py / setup.cfg → python; - package.json → javascript; - tsconfig.json → typescript +1. Auto-detect (lowest) run every plugin whose detect(ws, input) matches + against its EFFECTIVE config (overridden + detect_markers / extensions influence detection). + Multiple matches is NORMAL — there is no ambiguity. + +2. Config plugins the `plugins = [...]` array in code-ranker.toml / + Cargo.toml metadata → that list verbatim + +3. Console --plugins --plugins on the command line (highest) + → that list verbatim ``` -The resolved name must be one of the four compiled-in plugins — `rust`, -`python`, `javascript`, or `typescript` — which is then invoked in-process. JS -and TS are now **separate** plugins (no aliases): `detect` auto-selects, and a -project carrying both a `package.json` and a `tsconfig.json` is ambiguous → error -asking for an explicit `--plugin`. Multiple matching markers or none → the same -error. +So a list set in config or on the console is used as-is; auto-detect runs only +when no list is set anywhere, and the console wins when both are set. Each name +must be one of the compiled-in plugins (`rust`, `python`, `javascript`, +`typescript`, `go`, `c`, `cpp`, `csharp`, `markdown`); each is invoked in-process. +A language whose graph comes out empty is dropped from the report. + +**Invariant: one file ↔ exactly one language** — plugin file sets are disjoint. +Two active plugins that claim the same extension is a startup error (before +analysis), e.g. "extension `.h` is claimed by both `c` and `cpp` — adjust +`extensions`/`plugins`". + +**Errors:** + +- Zero languages detected → "could not determine any language in ``; + specify `plugins = [""]` in code-ranker.toml or `--plugins `". +- The scalar `plugin` config key is not recognized → error pointing to + `plugins = [...]`. #### Snapshot File Format @@ -977,48 +988,55 @@ The name template is resolved as **`--output.json.path` flag › `[output.json] path` in config › built-in default**, with placeholders `{project-dir}` (slugified workspace name), `{ts}`, `{git-hash}` (12-char short commit) and `{git-hash-N}` (first N chars). Example: `code-ranker report /path/to/axum-api ---plugin rust --output.json.path=.code-ranker/{ts}-{git-hash-3}.json` → +--plugins rust --output.json.path=.code-ranker/{ts}-{git-hash-3}.json` → `.code-ranker/20260522-112233-a3f.json` (or `axum-api-20260522-112233.json` if `[output.json] path = "{project-dir}-{ts}.json"`). -The file combines metadata and the `graphs` map (one entry per analysis level; -today only `files`) in one document. Each level bundles its semantics -dictionaries with the structural graph and the computed cycles/stats: +The file combines metadata with a `languages` map — one entry per analyzed +language. Each language carries its own `graphs` map (one entry per analysis +level; today only `files`), its `principles`, and its `prompt` scaffolding. Each +level bundles its semantics dictionaries with the structural graph and the +computed cycles/stats: ```json { - "schema_version": "4.0", + "schema_version": "5.0", "generated_at": "2026-05-22T11:22:33Z", - "command": "code-ranker report /path/to/axum-api --plugin rust", + "command": "code-ranker report /path/to/axum-api --plugins rust", "workspace": "/Users/alice/projects/code-ranker", "target": "/Users/alice/projects/axum-api", - "plugin": "rust", - "versions": { "code-ranker": "4.0.0", "rustc": "1.78.0" }, + "plugins": ["rust"], + "versions": { "code-ranker": "5.0.0", "rustc": "1.78.0" }, "roots": { "registry": "/Users/alice/.cargo/registry/src/index.crates.io-abc123", "target": "/Users/alice/projects/axum-api" }, "git": { "branch": "…", "commit": "a3f9c21b4d5e", "dirty_files": 4, "origin": "git@…:team/axum-api.git" }, "timings": [ { "stage": "rust", "ms": 0, "detail": "17 nodes from 8 files" }, … ], - "graphs": { - "files": { - "edge_kinds": { "uses": { "flow": true, "label": "uses", "description": "…" }, "contains": { "flow": false, … } }, - "node_attributes": { "cyclomatic": { "value_type": "int", "label": "Cyclomatic", "name": "Cyclomatic complexity", "short": "Cyclomatic", "formula": "branches + 1", "direction": "lower_better", "group": "complexity" }, "hk": { "value_type": "float", "calc": "sloc * (fan_in * fan_out) ** 2", "thresholds": { "info": 150000, "warning": 10000000 }, … }, … }, - "edge_attributes": { "visibility": { "value_type": "str", "label": "Visibility" } }, - "attribute_groups": { "complexity": { "label": "Complexity", "description": "…" }, … }, - "node_kinds": { "file": { "label": "File", "fill": "#dbe9f4", "stroke": "#4d6f9c" }, "external": { "external": true, … } }, - "cycle_kinds": { "mutual": { "label": "Mutual", "description": "…" } }, - "ui": { "default_sort": "hk", "columns": [...], "summary": [...], "sort": [...], "size": [...], "card": [...] }, - "nodes": [ - { "id": "{target}/src/a.rs", "kind": "file", "name": "a.rs", "sloc": 30, "cyclomatic": 1, "hk": 480, "cycle": "mutual", "visibility": "public", … }, - { "id": "ext:serde", "kind": "external", "name": "serde", "external": true, "version": "1.0.228", "path": "{registry}/serde-1.0.228" } - ], - "edges": [ { "source": "{target}/src/a.rs", "kind": "uses", "target": "ext:serde", "line": 15 }, … ], - "cycles": [ { "kind": "mutual", "nodes": ["{target}/src/a.rs", "{target}/src/b.rs"] } ], - "stats": { "cyclomatic": 1, "hk": 240, "sloc": 26, … } + "languages": { + "rust": { + "graphs": { + "files": { + "edge_kinds": { "uses": { "flow": true, "label": "uses", "description": "…" }, "contains": { "flow": false, … } }, + "node_attributes": { "cyclomatic": { "value_type": "int", "label": "Cyclomatic", "name": "Cyclomatic complexity", "short": "Cyclomatic", "formula": "branches + 1", "direction": "lower_better", "group": "complexity" }, "hk": { "value_type": "float", "calc": "sloc * (fan_in * fan_out) ** 2", "thresholds": { "info": 150000, "warning": 10000000 }, … }, … }, + "edge_attributes": { "visibility": { "value_type": "str", "label": "Visibility" } }, + "attribute_groups": { "complexity": { "label": "Complexity", "description": "…" }, … }, + "node_kinds": { "file": { "label": "File", "fill": "#dbe9f4", "stroke": "#4d6f9c" }, "external": { "external": true, … } }, + "cycle_kinds": { "mutual": { "label": "Mutual", "description": "…" } }, + "ui": { "default_sort": "hk", "columns": [...], "summary": [...], "sort": [...], "size": [...], "card": [...] }, + "nodes": [ + { "id": "{target}/src/a.rs", "kind": "file", "name": "a.rs", "sloc": 30, "cyclomatic": 1, "hk": 480, "cycle": "mutual", "visibility": "public", … }, + { "id": "ext:serde", "kind": "external", "name": "serde", "external": true, "version": "1.0.228", "path": "{registry}/serde-1.0.228" } + ], + "edges": [ { "source": "{target}/src/a.rs", "kind": "uses", "target": "ext:serde", "line": 15 }, … ], + "cycles": [ { "kind": "mutual", "nodes": ["{target}/src/a.rs", "{target}/src/b.rs"] } ], + "stats": { "cyclomatic": 1, "hk": 240, "sloc": 26, … } + } + }, + "principles": [ { "id": "ADP", "title": "…", "prompt": "…", "doc_url": "…", "sort_metric": "cycle", "connections": ["common","out"] }, … ], + "prompt": { "intro": "…", "task": "…", … } } - }, - "principles": [ { "id": "ADP", "title": "…", "prompt": "…", "doc_url": "…", "sort_metric": "cycle", "connections": ["common","out"] }, … ] + } } ``` @@ -1027,7 +1045,7 @@ objects); a file node carries no `path` (its id IS its path); an edge is external iff its `target` is an `ext:` node (no `edge.external`). Every metric's label/name/formula/`calc`/direction/threshold is in `node_attributes`, node/cycle kinds in `node_kinds`/`cycle_kinds`, column/sort ordering in `ui`, and the -Prompt-Generator principles in top-level `principles` — so the viewer renders +Prompt-Generator principles in each language's `principles` — so the viewer renders entirely from this data and hardcodes none of it. `workspace` is the directory where `code-ranker` was invoked (cwd). `target` @@ -1120,7 +1138,7 @@ emits an actionable error telling you to warm it (e.g. `cargo fetch`). ##### Full Mode — Step-by-Step ``` -code-ranker report /path/to/my-crate --plugin rust +code-ranker report /path/to/my-crate --plugins rust ``` 1. `code-ranker-cli` resolves the output path(s) from `--output.[.path]` / diff --git a/docs/PRD.md b/docs/PRD.md index 11cc954c..a82dd23e 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -100,7 +100,7 @@ language-specific, non-exportable, or single-level. | Term | Definition | |------|------------| | Plugin | A built-in language analyzer (`rust`, `python`, `javascript`, `typescript`, `go`, `c`, `cpp`, `csharp`, `markdown`) compiled into the `code-ranker` binary that analyzes a workspace and produces a single file graph in-process | -| Snapshot | A single self-contained JSON file combining metadata and the one `files` graph produced by a single analysis run | +| Snapshot | A single self-contained JSON file combining metadata and the per-language `files` graphs (under `languages..graphs`) produced by a single analysis run | | Graph | A directed graph whose nodes are source files (`file`) and third-party libraries (`external`), and whose edges are file dependencies (`uses`, `reexports`) | | External node | A third-party library recorded at depth 1 — one node per library (`ext:`), never expanded into its internals | | Node weight | The coupling metric for a file: sum of its incoming and outgoing internal edge counts | @@ -272,27 +272,28 @@ filename makes snapshots self-organizing without a registry. The plugins are built into the `code-ranker` binary; the valid plugin names are `rust`, `python`, `javascript`, `typescript`, `go`, `c`, `cpp`, `csharp`, and -`markdown` (JS and TS are **separate** plugins, no aliases; C and C++ share the -`cfamily` `#include`-graph module as peers). The `--plugin ` option (on -`check` / `report`) selects one of these built-ins. There is no external or -dynamic plugin loading. - -The plugin is resolved in the following order, stopping at the first match: - -1. **Explicit `--plugin `** on the command line (any value other - than `auto`) wins. -2. Otherwise the **`plugin` key in the config file** (`code-ranker.toml` / - `Cargo.toml#metadata.code-ranker`), if set and not `auto`. -3. Otherwise **auto-detect by project markers** in the workspace root - (`Cargo.toml` → `rust`; `pyproject.toml` / `setup.py` / `setup.cfg` - → `python`; `package.json` → `javascript`; `tsconfig.json` → `typescript`). - A project carrying both `package.json` and `tsconfig.json` is ambiguous and - requires an explicit `--plugin`. - -If `--plugin` resolves to a name that is not a built-in, or if `auto` -detection finds more than one marker or none, the analyzing command MUST -exit non-zero with a human-readable error naming the valid plugins and -asking for an explicit `--plugin`. +`markdown` (C and C++ share the `cfamily` `#include`-graph module as peers). A run +analyzes **all** active languages and produces one report covering every language. +There is no external or dynamic plugin loading. + +The active **set** of plugins is resolved in three levels, each fully replacing +the lower (no merge), highest wins: + +1. **Auto-detect** (lowest) — run every plugin whose `detect` matches the + workspace against its effective config (`Cargo.toml` → `rust`; + `pyproject.toml` / `setup.py` / `setup.cfg` → `python`; `package.json` → + `javascript`; `tsconfig.json` → `typescript`; …). Multiple matches is the + normal multi-language case — there is no "ambiguous project" error. +2. **`plugins` array in the config file** (`code-ranker.toml` / + `Cargo.toml#metadata.code-ranker`), if set → that list verbatim. +3. **`--plugins `** on the command line (highest) → that list verbatim. + +A language whose graph comes out empty is dropped. If **zero** languages are +detected and none is configured, the analyzing command MUST exit non-zero with a +human-readable error naming the valid plugins and asking for `plugins = [...]` / +`--plugins`. Two active plugins claiming the same file extension is a startup +error before analysis (one file maps to exactly one language). The scalar +`plugin` config key is not recognized — it errors pointing to `plugins = [...]`. **Rationale**: Built-in-only selection keeps the tool a single binary with nothing to install: every supported language ships compiled in, and adding @@ -305,7 +306,7 @@ process. - [x] `p1` - **ID**: `cpt-code-ranker-fr-rust-plugin` -The platform MUST ship a built-in Rust plugin (`--plugin rust`) for Cargo +The platform MUST ship a built-in Rust plugin (`--plugins rust`) for Cargo workspaces. The plugin MUST: - Derive the Rust module graph from `cargo metadata` and `mod` @@ -448,7 +449,7 @@ the `metadata` object on nodes/edges (e.g. Django, WordPress concepts); such extensions MUST be backward-compatible with the base schema and keep `kind` as `file` / `external`. -**Python plugin** (`--plugin python`) is shipped as a built-in in +**Python plugin** (`--plugins python`) is shipped as a built-in in `code-ranker-cli`. It uses `tree-sitter-python` to emit one `File` node per `.py` file and resolve imports: imports of project files become file→file `uses` edges (including `__init__.py` package imports pointing @@ -461,7 +462,7 @@ shared generic engine via the Python `Dialect` (`engine::compute` → a `MetricInputs`); the orchestrator writes the derived metrics onto each `File` node via `code_ranker_graph::write_metrics`. -**JavaScript / TypeScript plugin** (`--plugin javascript`) is shipped as a +**JavaScript / TypeScript plugin** (`--plugins javascript`) is shipped as a built-in in `code-ranker-cli`; one plugin handles `.js`, `.jsx`, `.ts`, and `.tsx`. It uses `tree-sitter-javascript` and `tree-sitter-typescript` to emit one `File` node per source file and resolve ES `import` statements @@ -548,7 +549,7 @@ subcommand at 10k nodes. - [x] `p1` - **ID**: `cpt-code-ranker-nfr-portability` JSON snapshot artifacts MUST conform to the Graph JSON Schema -(`schema_version: "4.0"`) and MUST be readable by the report generator and +(`schema_version: "5.0"`) and MUST be readable by the report generator and baseline comparison without migration within a major schema version. Generated HTML reports MUST open correctly in Chrome, Firefox, and Safari without installation. @@ -601,7 +602,7 @@ requires every count to mean exactly what it claims. **Stability**: unstable (pre-1.0) -Each supported language — `rust`, `python`, `javascript`, `typescript`, `go`, `c`, `cpp`, `csharp`, `markdown` — has a built-in analyzer, selected with `--plugin ` (see `cpt-code-ranker-fr-plugin-discovery`). Analyzers run **in-process and offline**: no subprocess, no external plugin binary, and no external/dynamic plugin loading, so a run needs nothing beyond the `code-ranker` binary. Test files are skipped by default (language-specific detection) and `.gitignore`/hidden files are honoured. Adding a language is an internal change to the binary; the analyzer contract, metric pipeline and registration mechanism are documented in [`DESIGN.md`](DESIGN.md), not in this product document. +Each supported language — `rust`, `python`, `javascript`, `typescript`, `go`, `c`, `cpp`, `csharp`, `markdown` — has a built-in analyzer; the active set is selected with `--plugins ` (see `cpt-code-ranker-fr-plugin-discovery`), and `--language ` focuses a single language for `report` / `recommend` scorecards and prompts. Analyzers run **in-process and offline**: no subprocess, no external plugin binary, and no external/dynamic plugin loading, so a run needs nothing beyond the `code-ranker` binary. Test files are skipped by default (language-specific detection) and `.gitignore`/hidden files are honoured. Adding a language is an internal change to the binary; the analyzer contract, metric pipeline and registration mechanism are documented in [`DESIGN.md`](DESIGN.md), not in this product document. ## 8. Use Cases @@ -616,7 +617,7 @@ the `code-ranker` binary is installed. **Main Flow**: -1. Developer runs `code-ranker report . --plugin rust` (analyzes the +1. Developer runs `code-ranker report . --plugins rust` (analyzes the workspace and writes both a snapshot and an HTML viewer in one step) 2. `code-ranker` writes `.code-ranker/axum-api-20260522-112233.json` (the snapshot) and `.code-ranker/axum-api-20260522-112233.html` (the viewer) @@ -625,7 +626,7 @@ the `code-ranker` binary is installed. 4. Developer identifies the heaviest files and decides what to refactor (For a non-blocking lint that gates on cycles/thresholds and writes no -files, the developer can instead run `code-ranker check . --plugin rust`.) +files, the developer can instead run `code-ranker check . --plugins rust`.) **Postconditions**: A self-contained HTML viewer exists at `.code-ranker/axum-api-20260522-112233.html`; no network access was required @@ -707,7 +708,7 @@ as a self-contained HTML report. snapshots; the verdict (`improved` / `degraded` / `neutral`) is present - [x] All P1 tools operate with zero outbound network calls - [x] Generated HTML reports contain no external resource references -- [x] JSON artifacts conform to the Graph JSON Schema (`schema_version: "4.0"`) +- [x] JSON artifacts conform to the Graph JSON Schema (`schema_version: "5.0"`) - [x] A `--baseline` comparison exits non-zero with a structured error on schema version mismatch - [ ] Every metric value equals the true count of what it measures — no false diff --git a/docs/ai-skill.md b/docs/ai-skill.md index 72759b49..cd32a21e 100644 --- a/docs/ai-skill.md +++ b/docs/ai-skill.md @@ -27,16 +27,18 @@ platform notes): [installation.md](installation.md). exits `0`. - **`docs `** — prints a reference doc to the terminal (no analysis; always exits `0`). Run `code-ranker docs ai` to bootstrap this playbook: with a language - plugin resolved it prints the full playbook + principle/metric catalog; with none - (no/ambiguous markers) it prints a brief intro and how to select one. + resolved it prints the full playbook + principle/metric catalog; with none + (no markers) it prints a brief intro and how to select one. `[input]` is polymorphic: a directory is analyzed; a `.json` snapshot is read back with no re-analysis. Keep old `.code-ranker/` snapshots — they are baselines. -`check` / `report` analyze one language, auto-detected from project markers. If a -directory has markers for several (e.g. Rust + Markdown), they stop with *"ambiguous -project … pass --plugin to choose"*: name the language with `--plugin `, or set -`plugin = ""` in a `code-ranker.toml` at the project root. (`docs` never needs this.) +`check` / `report` analyze **all** languages auto-detected from project markers and +produce one report covering every language — a directory with markers for several +(e.g. Rust + Markdown) just analyzes both, no error. To pin the set explicitly, pass +`--plugins ` or set `plugins = [...]` in a `code-ranker.toml` at the project +root. When a `--prompt ` or `--focus` resolves in two or more languages, add +`--language ` to pick which one to focus. ## The two metrics that matter diff --git a/docs/code-ranker-cli/CLI.md b/docs/code-ranker-cli/CLI.md index cbdd82c2..82205d71 100644 --- a/docs/code-ranker-cli/CLI.md +++ b/docs/code-ranker-cli/CLI.md @@ -24,7 +24,7 @@ exact command per entry (triage, CI gates, focused checks, baselines, AI prompts |---|---| | [`check`](#check) | A **verdict**: evaluates thresholds, cycle rules, and (with `--baseline`) regressions, prints diagnostics, and **exits non-zero** on violation. Writes no files. | | [`report`](#report) | **Artifacts**: an HTML viewer and/or a JSON snapshot. With `--baseline`, the HTML becomes a diff with a verdict. Can also emit a console **scorecard** triage and an AI **prompt** (see [Recommendations](#recommendations-scorecard--prompt)). Always exits `0`. | -| [`docs`](#docs) | A reference doc for a `` to stdout. Never analyzes, always exits `0` (an unknown subject exits non-zero). Resolves a language plugin (explicit `--plugin` > the `plugin` config key > none) to choose what to print; serves the AI playbook (`docs ai`), metric/principle indexes, category and metric spec cards, and full principle docs. | +| [`docs`](#docs) | A reference doc for a `` to stdout. Never analyzes, always exits `0` (an unknown subject exits non-zero). Resolves a single language (explicit `--plugin` > the first of `plugins` > auto-detect) to choose what to print; serves the AI playbook (`docs ai`), metric/principle indexes, category and metric spec cards, and full principle docs. | There are two analysis commands, split by *what they emit*: `check` produces an exit code (a CI gate), `report` produces files (a snapshot and a viewer). Both take the same @@ -90,14 +90,16 @@ code-ranker check snap.json --baseline main.json ## Common analysis options -`--plugin` and `--ignore` govern analysis itself and apply **only when `[input]` is a +`--plugins` and `--ignore` govern analysis itself and apply **only when `[input]` is a directory** — they are rejected with a snapshot input. `--config` is always accepted: -its rule and output keys apply to snapshots too, while analysis-only keys (e.g. `plugin`) -are ignored when reading one. +its rule and output keys apply to snapshots too, while analysis-only keys (e.g. +`plugins`) are ignored when reading one. | Flag | Meaning | |---|---| -| `--plugin ` | Plugin to use: `rust`, `python`, or `javascript` (covers TypeScript). `auto` (default) resolves the language automatically — see [Plugin resolution](#plugin-resolution). | +| `--plugins ` | Active languages, comma-separated and/or repeatable: `rust`, `python`, `javascript` (covers TypeScript), … . Overrides the config `plugins` array. Omitted everywhere ⇒ auto-detect **every** language present and analyze them all in one run — see [Plugin resolution](#plugin-resolution). | +| `--language ` | (`report` only) Focus the `scorecard` / `prompt` on one language. Not required when only one language is present; required when a `--prompt`/`--focus` selector resolves across several. See [Recommendations](#recommendations-scorecard--prompt). | +| `--config languages..=value` | Inline override of any plugin-config key (scalars / comma-lists). `languages.base.*` targets the shared base language. Deep tables go through a `[languages.]` TOML block — see [Config](#config). | | `--config ` | Repeatable. Load config from a file path, **or** override one setting inline (`KEY=VALUE`). Multiple files layer in command-line order (**last wins**) over the built-in defaults; inline `KEY=VALUE` applies after all files; passing any file disables auto-discovery of `code-ranker.toml`. See [Config](#config). | | `--ignore ` | Repeatable. Glob to exclude paths from analysis. Merged with config-file globs. | | `--git. ` | Override one of the snapshot's git metadata fields instead of reading it from `git`. See [Git metadata overrides](#git-metadata-overrides). | @@ -211,7 +213,7 @@ code-ranker check . --focus-path crates/code-ranker-graph --focus TST code-ranker check # Python project: per-file budgets — cap any single file -code-ranker check ./api --plugin python \ +code-ranker check ./api --plugins python \ --threshold file.cognitive=25 --threshold file.loc=300 # CI gate with machine-readable annotations; allow up to 7 chain cycles @@ -313,23 +315,26 @@ no analysis runs — as one TOML document with two top-level sections: baked into the binary) **deep-merged** with the discovered / `--config` file. Shows every effective `ignore` / `rules` / `output` / `levels` value, including the ones you did not set (inherited from the defaults). -- `[plugin]` — the active plugin's fully-merged language config (its inheritance chain - `defaults.toml ⊕ [base] ⊕ .toml`): principles, node/edge - kinds, the metric-engine role tables, etc. +- one `[languages.]` section for **every registered language** (not only the + active ones) — that language's fully-merged config (its inheritance chain + `defaults.toml ⊕ [base] ⊕ .toml`, then your `[languages.base]` / + `[languages.]` overrides): principles, node/edge kinds, the metric-engine role + tables, etc. -It honours `--plugin` and `--config`, so you can preview any combination: +It honours `--plugins` and `--config`, so you can preview any combination: ```sh # what `report` would use here, with my overrides folded in code-ranker report . --config ci/strict.toml --export-full-config /tmp/full.toml # the full Python plugin config (principles, vocab) -code-ranker report . --plugin python --export-full-config /tmp/python.toml +code-ranker report . --plugins python --export-full-config /tmp/python.toml ``` -It is a **diagnostic view** of every parameter you can override — because the two -sections use different schemas (and `principles` differs between the project and plugin -shapes), the file is not meant to be fed back as a single `--config`. +It is a **diagnostic view** of every parameter you can override — because the +project and language sections use different schemas (and `principles` differs +between the project and language shapes), the file is not meant to be fed back as a +single `--config`. ```sh # default: snapshot + viewer in .code-ranker/ @@ -457,6 +462,13 @@ Both rank modules with the same engine. The `scorecard` is steered by `--focus` a principle); without it the prompt auto-targets the single worst module. Both **require `--top 1`** for the `prompt`. +Both are **per language**: in a multi-language report use `--language ` to pick +which language the scorecard/prompt covers. It is optional when only one language is +present. When a `--focus ` or `--prompt ` selector resolves in +two or more languages and `--language` is omitted, the command errors and lists the +matching languages (e.g. *"`HK` found in languages rust, markdown — pass `--language +`"*). + > **Advisory, not a gate.** Unlike [`check`](#check), these never fail the build and carry > no exit code. They surface the worst hotspots against **the same thresholds `check` > enforces** — the `[rules.thresholds.file]` limits *you* configure — so the report shows @@ -578,7 +590,9 @@ code-ranker report . --output.prompt --top 1 `--prompt ` is the **named** counterpart of `--output.prompt`: it prints that principle/metric's fix-prompt to stdout and exits (shape the module list with `--top N` / `--focus-path`). It accepts a principle id (`SRP`, `ADP`) or a metric key (`hk`, -`cyclomatic`), case-insensitive, and writes no artifacts. +`cyclomatic`), case-insensitive, and writes no artifacts. If the `` resolves in +more than one active language, pass `--language ` to pick one (the command +otherwise errors and lists the candidates). ```sh code-ranker report . --prompt HK --top 1 # HK fix-prompt for the worst module @@ -591,16 +605,16 @@ e.g. `code-ranker docs HK` or `code-ranker docs ai`. ## `docs` ``` -code-ranker docs [--plugin ] [--config ] +code-ranker docs [--plugin ] [--config ] ``` `code-ranker docs ` prints a reference doc to stdout. It **never analyzes** and takes **no `[input]` positional** — config is auto-discovered from the current directory, -and `--plugin` (explicit `--plugin` > the `plugin` config key > auto-detect from cwd -markers) resolves which language's docs to serve. A reference doc is **strictly -per-language**, so every subject but `ai` **requires a resolved plugin**: with none (no -marker, or ambiguous markers) the command fails with the same diagnostic `check` / -`report` give. An unknown subject exits non-zero. Subject matching is +and the **singular** `--plugin ` flag (explicit `--plugin` > the first of the +`plugins` config/CLI list > auto-detect from cwd markers) resolves the **one** language +whose docs to serve. A reference doc is **strictly per-language**, so every subject but +`ai` **requires a resolved language**: with none detected the command fails with the same +diagnostic `check` / `report` give. An unknown subject exits non-zero. Subject matching is **separator/case-insensitive** — `fan_in`, `Fan-in`, and `FAN in` all resolve the same metric. @@ -608,7 +622,7 @@ metric. | `` | What it prints | |---|---| -| `ai` | The offline **AI-agent playbook** (from the embedded `base/AI.md`). With a plugin resolved → the full playbook **plus** the principle/metric catalog; with none → a brief intro and how to pick a plugin. | +| `ai` | The offline **AI-agent playbook** (from the embedded `base/AI.md`). With a language resolved → the full playbook **plus** the principle/metric catalog; with none → a brief intro and how to pick a language. | | `metrics` | An **index of every metric**, grouped by category. | | `principles` | An **index of every design principle**. | | a metric **category** (`loc`, `complexity`, `halstead`, `maintainability`, `coupling`) | The category's label/description **plus** its member metrics. | @@ -616,16 +630,16 @@ metric. | a **principle** id (`SRP`, `ADP`, … including project-defined `[principles.]`) | The principle's **full doc** (or a synthetic card for a doc-less custom principle). | | *(none, or an unknown subject)* | A **catalog of every subject**. No subject exits `0`; an unknown subject exits non-zero. | -`docs ai` always succeeds — even where `report` / `check` would stop with *"ambiguous -project … pass --plugin to choose"*: with a plugin resolved it prints the full playbook + -catalog (the full project-free playbook); with none it prints a +`docs ai` always succeeds — even where `report` / `check` would stop with *"could not +determine any language … pass --plugins to choose"*: with a language resolved it prints +the full playbook + catalog (the full project-free playbook); with none it prints a brief product intro **plus** a *Select a language* section (how to choose one with -`--plugin ` or the `plugin` key in `code-ranker.toml`, and the built-ins), withholding -the catalog until a language is chosen. +`--plugin ` or the `plugins` array in `code-ranker.toml`, and the built-ins), +withholding the catalog until a language is chosen. ```sh -code-ranker docs # the catalog of every subject (needs a resolved plugin) -code-ranker docs ai # auto-detect: full playbook, or how to pick a plugin +code-ranker docs # the catalog of every subject (needs a resolved language) +code-ranker docs ai # auto-detect: full playbook, or how to pick a language code-ranker docs ai --plugin rust # force a language → the full playbook + catalog code-ranker docs hk # the HK metric card + its full doc, to stdout code-ranker docs metrics # the metric index, grouped by category @@ -676,28 +690,61 @@ without re-analyzing anything — the JSON/HTML snapshot stands in for the code. ## Plugin resolution -With `--plugin auto` (the default), the plugin is resolved in this order (applies only -when `[input]` is a directory): +`code-ranker` analyzes **all** relevant languages in one run. The set of active +languages is resolved by this precedence (low → high), where each level **fully +replaces** the lower one (no merge): + +1. **Auto-detect** (lowest) — every plugin whose `detect()` matches the workspace. +2. **Config `plugins`** — the `plugins = [...]` array in `code-ranker.toml` / + `Cargo.toml#metadata.code-ranker`. +3. **Console `--plugins`** (highest) — the comma list / repeated flag. + +So a list set in config **or** on the console is used verbatim; auto-detect runs +only when no list is set anywhere; if both config and console set one, the console +wins (applies only when `[input]` is a directory). + +**Auto-detect** runs every plugin whose `detect()` matches, evaluated against its +**effective** config — so an overridden `detect_markers` / `extensions` (via +`[languages.]` or `--config languages..*`) changes what is detected. The +default markers are: + +- `Cargo.toml` → `rust` +- `pyproject.toml` / `setup.py` / `setup.cfg` → `python` +- `package.json` / `tsconfig.json` → `javascript` + +**Multiple matches are normal** — they are all analyzed and merged into one report; +there is no "ambiguous project" error. A language that yields an empty graph is +silently dropped. + +**Invariant: one file ↔ exactly one language.** The active plugins' file sets are +disjoint. + +Errors: + +- **No language detected** — auto-detect matches nothing: *"could not determine any + language in ``; specify `plugins = [""]` in code-ranker.toml or + `--plugins `"*. +- **Legacy `plugin` key** — the scalar `plugin = "..."` config key is not recognized; + the error points to `plugins = [...]`. +- **Extension conflict** — two active plugins claim the same file extension; a startup + error (before analysis), e.g. *"extension `.h` is claimed by both `c` and `cpp` — + adjust `extensions`/`plugins`"*. +- **Invalid `--plugins`** — an unknown language name in the list. -1. **Explicit `--plugin `** on the command line (any value other than `auto`) wins. -2. Otherwise the **`plugin` key in the config file** (`code-ranker.toml` / - `Cargo.toml#metadata.code-ranker`), if set and not `auto`. -3. Otherwise **auto-detect by project markers** in the workspace root: - - `Cargo.toml` → `rust` - - `pyproject.toml` / `setup.py` / `setup.cfg` → `python` - - `package.json` / `tsconfig.json` → `javascript` -4. If **more than one** marker matches, `code-ranker` errors and asks you to disambiguate - with an explicit `--plugin`. If **no** marker matches, it errors with the same hint. +See [ERRORS.md](ERRORS.md) for the full diagnostics. ## HTML viewer The HTML report is **self-contained**: the viewer app (Dagre graph layout, pan/zoom, a sortable node table for the single Files view, and the prompt-generator panel whose -principle buttons are read from `snapshot.principles` — the 13 design principles ADP / SRP / -OCP / LSP / ISP / DIP / DRY / KISS / LoD / MISU / CoI / YAGNI / CPX) **and the snapshot -data** are all embedded in -the one file. External library nodes render in a distinct amber colour with dashed -edges. No network, no telemetry — `open` it straight from disk. +principle buttons are read from `snapshot.languages..principles` — the 13 design +principles ADP / SRP / OCP / LSP / ISP / DIP / DRY / KISS / LoD / MISU / CoI / YAGNI / +CPX) **and the snapshot data** are all embedded in +the one file. A **language switcher** in the header (above the Files/Functions level +switcher) shows the active language and switches the whole report; it is hidden when +the report covers a single language. External library nodes render in a distinct +amber colour with dashed edges. No network, no telemetry — `open` it straight from +disk. The data is embedded as `"#), - "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}"); - } -} +#[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/config/load_test.rs b/crates/code-ranker-cli/src/config/load_test.rs index 3ce9a275..cb2df867 100644 --- a/crates/code-ranker-cli/src/config/load_test.rs +++ b/crates/code-ranker-cli/src/config/load_test.rs @@ -433,6 +433,135 @@ fn inline_override_sets_language_key() { 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() { diff --git a/crates/code-ranker-cli/src/docs_test.rs b/crates/code-ranker-cli/src/docs_test.rs index de4c9f28..70fa4f8a 100644 --- a/crates/code-ranker-cli/src/docs_test.rs +++ b/crates/code-ranker-cli/src/docs_test.rs @@ -221,6 +221,35 @@ 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(); diff --git a/crates/code-ranker-cli/src/pipeline/helpers.rs b/crates/code-ranker-cli/src/pipeline/helpers.rs index 1eb6b5ca..2599dc23 100644 --- a/crates/code-ranker-cli/src/pipeline/helpers.rs +++ b/crates/code-ranker-cli/src/pipeline/helpers.rs @@ -125,6 +125,9 @@ pub(super) fn assert_disjoint_languages( } 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", @@ -136,3 +139,55 @@ pub(super) fn assert_disjoint_languages( } 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 34008858..77c97660 100644 --- a/crates/code-ranker-cli/src/pipeline_test.rs +++ b/crates/code-ranker-cli/src/pipeline_test.rs @@ -221,3 +221,29 @@ fn detect_all_multi_and_empty() { sorted.sort_unstable(); assert_eq!(detected, sorted, "detect_all output is sorted"); } + +/// 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}" + ); +} diff --git a/crates/code-ranker-cli/src/recommend_test.rs b/crates/code-ranker-cli/src/recommend_test.rs index e1f37d10..33bedab0 100644 --- a/crates/code-ranker-cli/src/recommend_test.rs +++ b/crates/code-ranker-cli/src/recommend_test.rs @@ -858,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`. From 0888764f8cc4b86fe868850465f06d1b850d19b3 Mon Sep 17 00:00:00 2001 From: Roman Fedorov Date: Sun, 28 Jun 2026 09:28:59 +0300 Subject: [PATCH 09/10] docs(makefile): document GitHub-only prerelease (alpha) procedure Claude-Session: https://claude.ai/code/session_01Jcdvq3iTsqk74KfrXxZzcP --- Makefile | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/Makefile b/Makefile index 0fe1224f..79c0e7ff 100644 --- a/Makefile +++ b/Makefile @@ -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 From 00836423108bb64b3ab92ec610628f9bfa1f7496 Mon Sep 17 00:00:00 2001 From: Roman Fedorov Date: Sun, 28 Jun 2026 19:45:00 +0300 Subject: [PATCH 10/10] ci: dogfood via public code-ranker-ci scripts (build from source); drop pr-report Replace the bespoke pr-report.yml with the public report.yml@main reusable workflow (install_from_source: true builds code-ranker from this checkout, do_check: false keeps our PRs advisory). SARIF + verdict now live in code-ranker-ci, so the local pr-report.yml and its comment script are removed. Claude-Session: https://claude.ai/code/session_013ZmffaAWgLERpXGXHXBr7B --- .github/scripts/code_ranker_comment.py | 74 -------------- .github/workflows/code-ranker.yml | 12 ++- .github/workflows/pr-report.yml | 132 ------------------------- 3 files changed, 9 insertions(+), 209 deletions(-) delete mode 100644 .github/scripts/code_ranker_comment.py delete mode 100644 .github/workflows/pr-report.yml 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/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/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