From 9be67d3f52d599dc5323f6bfc7bd6cd1f113e4be Mon Sep 17 00:00:00 2001 From: Roman Fedorov Date: Sun, 28 Jun 2026 22:13:21 +0300 Subject: [PATCH 1/3] fix(cli): per-language commands, honest scorecard tiers, tests-aware detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-v5 follow-ups so generated commands, the scorecard, and language auto-detection are correct and consistent on multi-language repos. Per-language commands (docs are `docs `, not `--doc`): - thread the resolved language through the AI fix-prompt (`compose_prompt`), the `check` cycle/metric `fix`, the scorecard next-step hint, the `docs` index hints, and the HTML viewer's Prompt Generator — every emitted `code-ranker docs …` / `report --prompt …` now names the language. - `resolve_language_snap` returns the resolved language key (was snapshot only). - `docs ` localizes `` placeholders in served docs to the concrete language (`docs rust hk`, `--plugins rust`); `base` keeps the generic placeholder. Compact one-line `docs ai` catalog. - templates carry a `{lang}` placeholder (prompt.md, builtin.toml remediation). Scorecard `--focus ` tiers are now honest: each ranked module shows its own tier for that metric (`warn`/`info`/`—`) instead of a blanket `warn`; the header/next-step hint name the resolved language, not the first plugin. Auto-detect honors `[ignore] tests`: marker-less, extension-detected plugins (c/cpp/csharp/markdown) walk with the same tests/gitignore filters analysis uses, so a project whose only such files are test fixtures is no longer detected then warned about as empty. Docs synced (CLI.md, DESIGN.md, templates.md, ai-skill.md, USE-CASES.md, customization/README.md, plugins/base/{AI,HK}.md, contrib) + sample goldens. No format/CLI-surface contract changed, so no version bump. Claude-Session: https://claude.ai/code/session_01Jcdvq3iTsqk74KfrXxZzcP --- contrib/prompting-self-improve.md | 18 ++-- crates/code-ranker-cli/src/config/rules.rs | 18 ++-- crates/code-ranker-cli/src/docs.rs | 57 ++++++++----- crates/code-ranker-cli/src/docs_test.rs | 26 +++++- crates/code-ranker-cli/src/recommend.rs | 38 ++++++--- .../code-ranker-cli/src/recommend/prompt.rs | 19 +++-- .../src/recommend/scorecard.rs | 53 ++++++++++-- crates/code-ranker-cli/src/recommend_test.rs | 61 +++++++++++--- crates/code-ranker-cli/src/report.rs | 19 ++--- crates/code-ranker-cli/src/templates.rs | 82 ++++++------------- crates/code-ranker-cli/src/templates_test.rs | 49 ++++------- crates/code-ranker-graph/metrics/builtin.toml | 4 +- crates/code-ranker-graph/metrics/prompt.md | 10 ++- crates/code-ranker-graph/src/builtin_test.rs | 4 +- .../src/languages/c/mod.rs | 7 +- .../c/tests/sample/code-ranker-report.json | 2 +- .../src/languages/cfamily/mod.rs | 8 +- .../src/languages/cpp/mod.rs | 7 +- .../cpp/tests/sample/code-ranker-report.json | 2 +- .../src/languages/csharp/mod.rs | 6 +- .../src/languages/csharp/structure.rs | 10 ++- .../tests/sample/code-ranker-report.json | 2 +- .../go/tests/sample/code-ranker-report.json | 2 +- .../js/tests/sample/code-ranker-report.json | 6 +- .../md/tests/sample/code-ranker-report.json | 2 +- .../tests/sample/code-ranker-report.json | 6 +- .../rust/tests/sample/code-ranker-report.json | 6 +- .../ts/tests/sample/code-ranker-report.json | 6 +- .../src/assets/export-popup.js | 14 ++-- docs/DESIGN.md | 5 ++ docs/ai-skill.md | 20 ++--- docs/code-ranker-cli/CLI.md | 23 ++++-- docs/code-ranker-cli/USE-CASES.md | 6 +- docs/customization/README.md | 2 +- docs/templates.md | 10 +-- plugins/base/AI.md | 29 ++++--- plugins/base/HK.md | 2 +- 37 files changed, 384 insertions(+), 257 deletions(-) diff --git a/contrib/prompting-self-improve.md b/contrib/prompting-self-improve.md index 68191afd..6a3aaac9 100644 --- a/contrib/prompting-self-improve.md +++ b/contrib/prompting-self-improve.md @@ -66,9 +66,9 @@ of these and rebuild (see Setup) — all are baked into the binary: `crates/code-ranker-plugins/src/languages//config.toml`). - **scaffolding** (intro / doc-note / task / focus prose) — `crates/code-ranker-graph/metrics/prompt.md`. -- **the full reference doc** the agent reads via `docs ` — +- **the full reference doc** the agent reads via `docs ` — `plugins//.md` (e.g. `ADP.md`), and the offline entry point - `plugins/base/AI.md` (`docs ai`). + `plugins/base/AI.md` (`docs ai`). Change the **smallest** lever that fixes the observed failure. @@ -151,12 +151,12 @@ nothing eval-related is left in `PROJECT`. 1. **Clean start.** `PROJECT` on `main`, working tree clean. 2. **Fresh agent session**, model = `MODEL`, **empty context**. Bootstrap it with the offline playbook only — no extra hints: have it read - `code-ranker docs ai` (overview + catalog) and `docs ` (the deep + `code-ranker docs ai` (overview + catalog) and `docs ` (the deep doc). This is what a real user would do, so it tests the *prompt*, not your 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 . --prompt > $RUN/prompt.md` + `code-ranker report . --plugins --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 @@ -273,7 +273,7 @@ Layout (one build → one `_` folder → one subfolder per ru Each run is a **fresh session** of `MODEL` with **no carried context** — start a new one, never `--continue`/`--resume`. Keep `PROJECT` free of a code-ranker-specific -`CLAUDE.md`/memory so only `docs ai` primes the agent; otherwise you're testing the +`CLAUDE.md`/memory so only `docs ai` primes the agent; otherwise you're testing the priming, not the prompt. **Watch the agent's working directory.** Launch it *inside* `PROJECT` (the interactive @@ -297,13 +297,13 @@ and note in `metrics.csv` which basis the run used. Then give it **one** opening message (the bootstrap), nothing else: - > Read `code-ranker docs ai`, then fix the worst `` in this + > Read `code-ranker docs ai`, then fix the worst `` in this > project. Show me the plan before changing code. Headless one-shot (scriptable, but weaker for the multi-step loop): ```sh - cd PROJECT && claude -p "Read \`code-ranker docs ai\`, then fix the worst …" --model haiku + cd PROJECT && claude -p "Read \`code-ranker docs ai\`, then fix the worst …" --model haiku ``` - **Other agents** (Cursor, …): open a **New Chat** (not a continued thread), select @@ -314,7 +314,7 @@ and note in `metrics.csv` which basis the run used. The transcript is the **primary tuning data** — it shows *where* a cheaper model diverged (skipped `docs`, picked the wrong cycle, hacked the metric). Save it raw, **verbatim, no summary**, into `$RUN/chat.*`. It must include the bootstrap -(`docs ai` / `docs ` reads), the task, and **every** assistant turn — its +(`docs ai` / `docs ` reads), the task, and **every** assistant turn — its reasoning **and** the tool calls (the `code-ranker` commands + their output), through the final fix and the test run. @@ -360,7 +360,7 @@ Columns, grouped by objective (most are extractable from the run's artifacts; th | `api_duration_s` | cost | transcript | ↓ the **API-only subset** of `wall_s` (active model time, `result.duration_api_ms`). `wall_s − api_duration_s` ≈ local tool execution + queueing. Blank when there's no session `result` event (subagent log) | | `files_changed` | cost | diff | context — edit footprint (not better/worse alone) | | `loc_added` / `loc_removed` | cost | PROJECT branch `git diff --shortstat` | precise edit footprint; a fix far larger than the reference's is a smell (also catches committed litter) | -| `read_doc_ai` / `read_doc_focus` | clarity | transcript | 1/0 — read `docs ai` / `docs ` | +| `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 (`--prompt`) vs improvising | diff --git a/crates/code-ranker-cli/src/config/rules.rs b/crates/code-ranker-cli/src/config/rules.rs index 875675d1..5bba8f68 100644 --- a/crates/code-ranker-cli/src/config/rules.rs +++ b/crates/code-ranker-cli/src/config/rules.rs @@ -56,7 +56,9 @@ pub fn rule_doc( return Some(RuleDoc { title: c.label.clone(), why: c.description.clone(), - fix: c.remediation.clone(), + // `{lang}` in an authored remediation → the resolved language, so a + // `code-ranker docs {lang} ADP` pointer is runnable as printed. + fix: c.remediation.clone().map(|r| r.replace("{lang}", lang)), }); } let metric = id.rsplit('.').next().unwrap_or(id); @@ -66,11 +68,15 @@ pub fn rule_doc( // 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 report --plugins {lang} --prompt {metric}` to generate an AI fix-prompt." - )) - }); + let fix = s + .remediation + .clone() + .map(|r| r.replace("{lang}", lang)) + .or_else(|| { + Some(format!( + "Run `code-ranker report --plugins {lang} --prompt {metric}` to generate an AI fix-prompt." + )) + }); Some(RuleDoc { title: s.name.clone().or_else(|| s.label.clone()), why: s.description.clone(), diff --git a/crates/code-ranker-cli/src/docs.rs b/crates/code-ranker-cli/src/docs.rs index 84b979e8..3f361b95 100644 --- a/crates/code-ranker-cli/src/docs.rs +++ b/crates/code-ranker-cli/src/docs.rs @@ -76,7 +76,7 @@ pub(crate) fn run( // `docs ai` → the offline AI-agent playbook. if subject.is_some_and(|s| templates::normalize_id(s) == "ai") { - emit(templates::ai_doc()?); + emit(templates::ai_doc(language)?, language); return Ok(()); } @@ -84,10 +84,7 @@ pub(crate) fn run( let Some(subject) = subject else { // `docs `: the full subject catalog for that language. - print!( - "{}", - templates::with_trailing_newline(render_catalog(&specs, language, None)) - ); + emit(render_catalog(&specs, language, None), language); return Ok(()); }; @@ -95,34 +92,48 @@ pub(crate) fn run( // so `fan_in`, `Fan-in`, and `FAN in` all resolve the same metric. let want = templates::normalize_id(subject); if want == "metrics" { - emit(render_metrics_index(&specs)); + emit(render_metrics_index(&specs, language), language); } else if want == "principles" { - emit(render_principles_index(&specs)); + emit(render_principles_index(&specs, language), language); } else if let Some(cat) = category_key(&specs, subject) { - emit(render_category(&specs, &cat)); + emit(render_category(&specs, language, &cat), language); } else if let Some(p) = specs .principles .iter() .find(|p| templates::normalize_id(&p.id) == want) { - emit(render_principle(&specs, &p.id)?); + emit(render_principle(&specs, &p.id)?, language); } else if let Some(key) = specs .node_attributes .keys() .find(|k| templates::normalize_id(k) == want) { - emit(render_metric(&specs, key)); + emit(render_metric(&specs, key), language); } 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, language, Some(subject))); + emit(render_catalog(&specs, language, Some(subject)), language); bail!("unknown docs subject {subject:?} for language {language:?} — see the list above"); } Ok(()) } -fn emit(md: String) { - print!("{}", templates::with_trailing_newline(md)); +fn emit(md: String, lang: &str) { + print!( + "{}", + templates::with_trailing_newline(localize_lang(md, lang)) + ); +} + +/// Make instructional `` placeholders concrete in served per-language docs, so +/// commands print runnable as-is (`docs rust hk`, `--plugins rust`). `base` is the +/// language-agnostic catalog, so its generic `` stays a placeholder. +fn localize_lang(md: String, lang: &str) -> String { + if lang == "base" { + md + } else { + md.replace("", lang) + } } /// `base` (the language-agnostic catalog) or any registered plugin name. @@ -401,31 +412,33 @@ fn principles_block(specs: &DocSpecs) -> String { .collect() } -/// `docs metrics`: every metric, grouped by category. -fn render_metrics_index(specs: &DocSpecs) -> String { +/// `docs metrics`: every metric, grouped by category. +fn render_metrics_index(specs: &DocSpecs, lang: &str) -> String { format!( - "Metrics — print one with `code-ranker docs `:\n{}", + "Metrics — print one with `code-ranker docs {lang} `:\n{}", categories_block(specs) ) } -/// `docs principles`: every design principle. -fn render_principles_index(specs: &DocSpecs) -> String { +/// `docs principles`: every design principle. +fn render_principles_index(specs: &DocSpecs, lang: &str) -> String { format!( - "Principles — print one with `code-ranker docs `:\n\n{}", + "Principles — print one with `code-ranker docs {lang} `:\n\n{}", principles_block(specs) ) } -/// `docs `: the category's human label + description + its member metrics. -fn render_category(specs: &DocSpecs, key: &str) -> String { +/// `docs `: the category's human label + description + its member metrics. +fn render_category(specs: &DocSpecs, lang: &str, key: &str) -> String { // Single-category view: the human label is the title (the key was just typed), // so there is no `key: Label` echo. let mut out = category_label(specs, key); if let Some(d) = specs.groups.get(key).and_then(|g| g.description.as_deref()) { out.push_str(&format!("\n{d}")); } - out.push_str("\n\nMetrics — print one with `code-ranker docs `:\n"); + out.push_str(&format!( + "\n\nMetrics — print one with `code-ranker docs {lang} `:\n" + )); for (k, spec) in metrics_in_category(specs, key) { out.push_str(&format!(" - {k}: {}", metric_name(spec, k))); if let Some(d) = spec.description.as_deref() { diff --git a/crates/code-ranker-cli/src/docs_test.rs b/crates/code-ranker-cli/src/docs_test.rs index 70fa4f8a..21556c48 100644 --- a/crates/code-ranker-cli/src/docs_test.rs +++ b/crates/code-ranker-cli/src/docs_test.rs @@ -54,7 +54,7 @@ fn category_subject_resolves_case_insensitively() { #[test] fn render_category_lists_label_description_and_members() { - let out = render_category(&specs(), "loc"); + let out = render_category(&specs(), "rust", "loc"); assert!(out.contains("Lines of Code"), "header (human label): {out}"); assert!( out.contains("Lines of code breakdown"), @@ -120,7 +120,7 @@ fn catalog_lists_every_subject_class() { #[test] fn metrics_index_lists_categories_and_members() { - let out = render_metrics_index(&specs()); + let out = render_metrics_index(&specs(), "rust"); assert!( out.contains("loc — Lines of code breakdown"), "category: {out}" @@ -130,7 +130,7 @@ fn metrics_index_lists_categories_and_members() { #[test] fn principles_index_lists_each_principle() { - let out = render_principles_index(&specs()); + let out = render_principles_index(&specs(), "rust"); assert!(out.contains("- TSR: Test Ratio"), "principle listed: {out}"); } @@ -138,7 +138,7 @@ fn principles_index_lists_each_principle() { fn principles_block_reports_when_the_plugin_defines_none() { let mut s = specs(); s.principles.clear(); - let out = render_principles_index(&s); + let out = render_principles_index(&s, "rust"); assert!(out.contains("(none"), "empty principles note: {out}"); } @@ -237,6 +237,24 @@ fn build_specs_base_uses_neutral_catalog() { ); } +/// Served per-language docs make `` placeholders concrete so commands print +/// runnable; the language-agnostic `base` catalog keeps the placeholder. +#[test] +fn localize_lang_substitutes_concrete_language_but_not_base() { + assert_eq!( + localize_lang( + "`code-ranker docs hk` then `--plugins `".into(), + "rust", + ), + "`code-ranker docs rust hk` then `--plugins rust`" + ); + assert_eq!( + localize_lang("--plugins ".into(), "base"), + "--plugins ", + "base keeps the generic placeholder" + ); +} + /// With no language markers present, `languages_hint` lists every available /// language rather than the project's detected set. #[test] diff --git a/crates/code-ranker-cli/src/recommend.rs b/crates/code-ranker-cli/src/recommend.rs index b8472240..4a9f4f33 100644 --- a/crates/code-ranker-cli/src/recommend.rs +++ b/crates/code-ranker-cli/src/recommend.rs @@ -24,7 +24,9 @@ use code_ranker_plugin_api::{ }; use std::collections::HashMap; -/// Select the `LanguageSnapshot` to use for recommendations. +/// Select the `LanguageSnapshot` to use for recommendations, returning both the +/// resolved language KEY and its snapshot — callers need the name to render +/// per-language commands (e.g. `code-ranker docs ` in a prompt). /// /// Resolution order: /// 1. `--language` explicitly given → use that language or error. @@ -37,23 +39,28 @@ pub fn resolve_language_snap<'a>( snap: &'a Snapshot, language: Option<&str>, id: Option<&str>, -) -> Result<&'a LanguageSnapshot> { +) -> Result<(&'a str, &'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(", ") - ) - }); + return snap + .languages + .get_key_value(&canon) + .map(|(k, v)| (k.as_str(), v)) + .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")); + let (k, v) = snap.languages.iter().next().expect("len==1"); + return Ok((k.as_str(), v)); } // Multiple languages: try to resolve the id across all of them. @@ -73,7 +80,13 @@ pub fn resolve_language_snap<'a>( .collect(); match matches.as_slice() { - [one] => return Ok(snap.languages.get(*one).expect("key from languages")), + [one] => { + let (k, v) = snap + .languages + .get_key_value(*one) + .expect("key from languages"); + return Ok((k.as_str(), v)); + } [] => {} // fall through to first-language default langs => anyhow::bail!( "{focus_id:?} found in languages: {}; specify --language to disambiguate", @@ -84,8 +97,9 @@ pub fn resolve_language_snap<'a>( // Fall back to the first language (BTreeMap order, deterministic). snap.languages - .values() + .iter() .next() + .map(|(k, v)| (k.as_str(), v)) .context("snapshot has no languages; regenerate the report with `code-ranker report`") } diff --git a/crates/code-ranker-cli/src/recommend/prompt.rs b/crates/code-ranker-cli/src/recommend/prompt.rs index d5b4cc3c..aab085f2 100644 --- a/crates/code-ranker-cli/src/recommend/prompt.rs +++ b/crates/code-ranker-cli/src/recommend/prompt.rs @@ -10,15 +10,24 @@ use anyhow::{Result, bail}; use code_ranker_graph::level_graph::LevelGraph; use code_ranker_plugin_api::{Principle, PromptTemplate, node::Node}; +/// Substitute the template placeholders in one scaffolding line: `{id}` → the +/// active principle/metric id, `{lang}` → the resolved language (so a `docs` +/// pointer reads `code-ranker docs `). +fn fill(line: &str, id: &str, lang: &str) -> String { + line.replace("{id}", id).replace("{lang}", lang) +} + /// Compose the AI prompt for one principle — the same Markdown the HTML viewer's /// Prompt Generator produces: intent + summary + principle link + task checklist, /// then the ranked offending modules, then the principle's connection lists. /// `focus_paths` (empty = no restriction) narrows the ranked modules to a subtree. +#[allow(clippy::too_many_arguments)] // a flat prompt-builder signature reads clearer than a params struct here pub fn compose_prompt( level: &LevelGraph, principles: &[Principle], tmpl: &PromptTemplate, principle_id: &str, + lang: &str, sev: Severity, top: Option, focus_paths: &[String], @@ -64,7 +73,7 @@ pub fn compose_prompt( // Scaffolding prose (intro / doc-note / task protocol / focus) is DATA from // the snapshot's `prompt` template; only the Markdown skeleton + the principle's // own title/summary are assembled here. The doc-note points at the offline - // `--doc ` command (no network URL). + // `code-ranker docs ` command (no network URL). let mut head = String::new(); head.push_str(&format!("# {}\n\n", principle.title)); head.push_str(&tmpl.intro); @@ -72,14 +81,14 @@ pub fn compose_prompt( head.push_str(&principle.prompt); head.push_str("\n\n"); // A doc exists for this principle/metric (signalled by `doc_url`): point the - // agent at the offline `--doc ` command rather than a network URL. + // agent at the offline `code-ranker docs ` command rather than a network URL. if principle.doc_url.is_some() { - head.push_str(&tmpl.doc_note.replace("{id}", principle_id)); + head.push_str(&fill(&tmpl.doc_note, principle_id, lang)); head.push_str("\n\n"); } head.push_str("## Task\n\n"); for line in &tmpl.task { - head.push_str(&line.replace("{id}", principle_id)); + head.push_str(&fill(line, principle_id, lang)); head.push('\n'); } head.push('\n'); @@ -128,7 +137,7 @@ pub fn compose_prompt( let m = &principle.sort_metric; let label = attr_short(level, m); // A single target reads as one module, not a ranking; the formula and a - // repeated description are dropped (they live in `--doc `). + // repeated description are dropped (they live in `code-ranker docs `). let mut s = if modules.len() == 1 { format!("## Target module ({label})\n\n") } else { diff --git a/crates/code-ranker-cli/src/recommend/scorecard.rs b/crates/code-ranker-cli/src/recommend/scorecard.rs index 7f9a9e4a..a2e73109 100644 --- a/crates/code-ranker-cli/src/recommend/scorecard.rs +++ b/crates/code-ranker-cli/src/recommend/scorecard.rs @@ -28,9 +28,31 @@ struct Row { top: String, } +/// The severity tag shown on a worst-modules row. In the unfocused view it is the +/// node's worst breach; in the metric-focus view it is the focused metric's own +/// tier for that node — `Below` when the value is under the threshold (or the +/// metric has none), so a ranked-but-not-breaching module is labeled honestly +/// rather than always shown as `warn`. +#[derive(Clone, Copy)] +enum Tier { + Warn, + Info, + Below, +} + +impl Tier { + fn label(self) -> &'static str { + match self { + Tier::Warn => "warn", + Tier::Info => "info", + Tier::Below => "—", + } + } +} + /// One row of the worst-modules list. struct ModRow { - is_warning: bool, + tier: Tier, path: String, head: String, rest: Vec, @@ -163,9 +185,11 @@ pub fn render_scorecard( } // ── Next-step hint ─────────────────────────────────────────────────────── - out.push_str( - "\n→ code-ranker report . --prompt (AI fix-prompt to stdout)\n", - ); + // Pin `--plugins ` so the fix-prompt targets the same language this + // scorecard is for (a multi-language repo would otherwise re-resolve it). + out.push_str(&format!( + "\n→ code-ranker report . --plugins {plugin} --prompt (AI fix-prompt to stdout)\n" + )); Ok(out) } @@ -318,7 +342,7 @@ fn cycle_mod_rows(out: &mut String, level: &LevelGraph, top: Option) -> V for (g, members) in &groups { for n in members { mod_rows.push(ModRow { - is_warning: true, + tier: Tier::Warn, path: clean_path(&n.id), head: g.kind.clone(), rest: Vec::new(), @@ -340,19 +364,30 @@ fn metric_mod_rows( focus_paths: &[String], ) -> Vec { let reco = reco_for(level, m); + // The focused metric's own tiers: tag each ranked module by where its value + // actually lands (warn > warning, info > info), `Below` otherwise — never a + // blanket `warn`. A metric with no configured threshold → every row `Below`. + let th = super::thresholds_for(level, m); reco.sorted .iter() .filter(|n| in_focus(n, focus_paths)) .take(limit) .map(|n| { - let head = match num(n, m) { + let v = num(n, m); + let head = match v { Some(v) if v != 0.0 => { format!("{} {}", attr_short(level, m), fmt_val(v)) } _ => attr_short(level, m).to_string(), }; + let value = v.unwrap_or(0.0); + let tier = match th { + Some(t) if value > t.warning => Tier::Warn, + Some(t) if value > t.info => Tier::Info, + _ => Tier::Below, + }; ModRow { - is_warning: true, + tier, path: clean_path(&n.id), head, rest: Vec::new(), @@ -412,7 +447,7 @@ fn breach_row(level: &LevelGraph, n: &Node, breaches: &[Breach]) -> ModRow { .map(|b| breach_label(level, &b.metric, None)) .collect(); ModRow { - is_warning: n_warn > 0, + tier: if n_warn > 0 { Tier::Warn } else { Tier::Info }, path: clean_path(&n.id), head, rest, @@ -438,7 +473,7 @@ fn breach_label(level: &LevelGraph, metric: &str, value: Option) -> String fn render_mod_rows(out: &mut String, mod_rows: &[ModRow]) { let path_w = mod_rows.iter().map(|r| r.path.len()).max().unwrap_or(0); for (i, r) in mod_rows.iter().enumerate() { - let tier = if r.is_warning { "warn" } else { "info" }; + let tier = r.tier.label(); let mut line = format!("{:>2} {:<4} {:"), - "next-step hint" + sc.contains("→ code-ranker report . --plugins rust --prompt "), + "next-step hint pins the language" ); } @@ -409,6 +412,39 @@ fn scorecard_narrowed_metric_lists_ranked_modules() { ); } +/// In the metric-focus view each ranked module is tagged by its OWN tier for that +/// metric — `warn` over the warning line, `info` over the info line, `—` below +/// both — never a blanket `warn`. (`level_with` sets sloc info=50 / warning=200.) +#[test] +fn scorecard_focus_metric_tags_each_module_by_its_actual_tier() { + let level = level_with(vec![ + file_node("{target}/over.rs", &[("sloc", AttrValue::Int(300))]), // > 200 → warn + file_node("{target}/mid.rs", &[("sloc", AttrValue::Int(100))]), // 50..200 → info + file_node("{target}/low.rs", &[("sloc", AttrValue::Int(10))]), // < 50 → below + ]); + let sc = render_scorecard( + "rust", + &level, + &[srp_principle()], + &[Severity::Warning], + Some(3), + Some(&Focus::Metric("sloc".into())), + &[], + ) + .unwrap(); + // The second whitespace token of a module line is its tier label. + let tier_of = |path: &str| -> String { + sc.lines() + .find(|l| l.contains(path)) + .and_then(|l| l.split_whitespace().nth(1)) + .unwrap_or("") + .to_string() + }; + assert_eq!(tier_of("over.rs"), "warn", "over warning line: {sc}"); + assert_eq!(tier_of("mid.rs"), "info", "over info, under warning: {sc}"); + assert_eq!(tier_of("low.rs"), "—", "under both → not a breach: {sc}"); +} + /// Narrowing on the cycle (ADP) principle lists every member of the top cycle /// (the `narrow.is_some()` cycle branch), with the "one cycle" header. #[test] @@ -605,6 +641,7 @@ fn compose_prompt_lists_multiple_cycles() { &[adp_principle()], &code_ranker_graph::prompt_template(), "ADP", + "rust", Severity::Auto, Some(2), &[], @@ -790,6 +827,7 @@ fn compose_prompt_metric_lens_omits_duplicate_description() { &[principle], &code_ranker_graph::prompt_template(), "hk", + "rust", Severity::Auto, Some(1), &[], @@ -802,7 +840,7 @@ fn compose_prompt_metric_lens_omits_duplicate_description() { ); assert!( !md.contains("**Formula:**"), - "formula is dropped from the prompt — it lives in `--doc `: {md}" + "formula is dropped from the prompt — it lives in `docs `: {md}" ); } @@ -903,7 +941,7 @@ fn resolve_language_snap_explicit_resolves_alias() { lang_snap(level_with(vec![]), vec![srp_principle()]), ), ]); - let ls = resolve_language_snap(&snap, Some("py"), None).unwrap(); + let (_, ls) = resolve_language_snap(&snap, Some("py"), None).unwrap(); assert_eq!(ls.principles[0].id, "SRP", "py alias resolved to python"); } @@ -935,7 +973,7 @@ fn resolve_language_snap_single_language_ignores_id() { "rust", lang_snap(level_with(vec![]), vec![srp_principle()]), )]); - let ls = resolve_language_snap(&snap, None, Some("anything")).unwrap(); + let (_, ls) = resolve_language_snap(&snap, None, Some("anything")).unwrap(); assert_eq!(ls.principles[0].id, "SRP", "the only language is used"); } @@ -951,7 +989,7 @@ fn resolve_language_snap_id_matches_one_principle() { lang_snap(LevelGraph::default(), vec![srp_principle()]), ), ]); - let ls = resolve_language_snap(&snap, None, Some("SRP")).unwrap(); + let (_, ls) = resolve_language_snap(&snap, None, Some("SRP")).unwrap(); assert_eq!( ls.principles[0].id, "SRP", "matched the principle's language" @@ -967,7 +1005,7 @@ fn resolve_language_snap_id_matches_metric_in_one() { ("rust", lang_snap(level_with(vec![]), vec![])), ("python", lang_snap(LevelGraph::default(), vec![])), ]); - let ls = resolve_language_snap(&snap, None, Some("hk")).unwrap(); + 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" @@ -1016,7 +1054,7 @@ fn resolve_language_snap_id_none_match_falls_to_first() { ), ]); // "ZZZ" is neither a principle nor a metric anywhere → first key wins. - let ls = resolve_language_snap(&snap, None, Some("ZZZ")).unwrap(); + let (_, ls) = resolve_language_snap(&snap, None, Some("ZZZ")).unwrap(); assert_eq!(ls.principles[0].id, "SRP", "python sorts before rust"); } @@ -1033,7 +1071,7 @@ fn resolve_language_snap_no_id_uses_first() { lang_snap(LevelGraph::default(), vec![srp_principle()]), ), ]); - let ls = resolve_language_snap(&snap, None, None).unwrap(); + let (_, ls) = resolve_language_snap(&snap, None, None).unwrap(); assert_eq!(ls.principles[0].id, "SRP", "python sorts before rust"); } @@ -1089,6 +1127,7 @@ fn compose_prompt_single_focus_abbreviates_in_and_out_edges() { &[principle], &code_ranker_graph::prompt_template(), "HK", + "rust", Severity::Auto, Some(1), &[], diff --git a/crates/code-ranker-cli/src/report.rs b/crates/code-ranker-cli/src/report.rs index dea33a6e..0da3de48 100644 --- a/crates/code-ranker-cli/src/report.rs +++ b/crates/code-ranker-cli/src/report.rs @@ -198,7 +198,8 @@ fn run_direct(args: &AnalyzeArgs, reco: &ReportReco) -> Result<()> { // `--prompt `: compose the named principle/metric prompt to stdout. let id = reco.prompt_id.as_deref().expect("prompt_id is set"); - let lang_snap = recommend::resolve_language_snap(snap, reco.language.as_deref(), Some(id))?; + let (lang, lang_snap) = + recommend::resolve_language_snap(snap, reco.language.as_deref(), Some(id))?; let level = lang_snap .graphs .get("files") @@ -221,6 +222,7 @@ fn run_direct(args: &AnalyzeArgs, reco: &ReportReco) -> Result<()> { principles_for_prompt, &lang_snap.prompt, &principle_id, + lang, recommend::Severity::Auto, reco.top, &reco.focus_path, @@ -241,7 +243,7 @@ fn write_scorecard( commit: Option<&str>, generated_at: DateTime, ) -> Result<()> { - let lang_snap = + let (lang, lang_snap) = recommend::resolve_language_snap(snap, reco.language.as_deref(), reco.focus.as_deref())?; let level = lang_snap .graphs @@ -265,16 +267,11 @@ fn write_scorecard( .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") - }); + // Header / next-step hint must name the language whose level is shown — the + // one `resolve_language_snap` picked (honoring `--language` / `--focus`), not + // an arbitrary first plugin (which mislabeled the card on multi-language repos). let txt = recommend::render_scorecard( - plugin_label, + lang, level, &lang_snap.principles, &severities, diff --git a/crates/code-ranker-cli/src/templates.rs b/crates/code-ranker-cli/src/templates.rs index d1a7df17..15976391 100644 --- a/crates/code-ranker-cli/src/templates.rs +++ b/crates/code-ranker-cli/src/templates.rs @@ -135,38 +135,6 @@ const TLDR_INDEX_MARKER: &str = ""; const AI_SELECT_START: &str = ""; const AI_SELECT_END: &str = ""; -/// A base doc's one-paragraph summary for the index: its `**TL;DR**` paragraph -/// (lines from the `**TL;DR**` line to the next blank line, joined into one), or -/// the first prose paragraph after the H1 when there is no explicit TL;DR. -fn doc_summary(md: &str) -> Option { - let lines: Vec<&str> = md.lines().collect(); - let para_from = |start: usize| -> Option { - let mut buf = Vec::new(); - for l in &lines[start..] { - let t = l.trim(); - if t.is_empty() || t.starts_with('#') { - if buf.is_empty() { - continue; - } - break; - } - buf.push(t); - } - (!buf.is_empty()).then(|| buf.join(" ")) - }; - if let Some(i) = lines - .iter() - .position(|l| l.trim_start().starts_with("**TL;DR**")) - { - return para_from(i); - } - let h1 = lines - .iter() - .position(|l| l.starts_with("# ")) - .map_or(0, |i| i + 1); - para_from(h1) -} - /// A doc or prompt printed to stdout must end in exactly one trailing newline so /// the shell prompt resumes on its own line. Returns `md` with a newline ensured. /// Shared by the `docs` stdout paths so the rule lives in one @@ -178,22 +146,19 @@ pub(crate) fn with_trailing_newline(mut md: String) -> String { md } -/// One catalog entry: a `### ` heading + a `docs <stem>` pointer, plus the -/// doc's one-paragraph summary when it has one. Split out from [`tldr_index`] so the -/// no-summary arm is exercised by a unit test without needing a summary-less doc in -/// the real corpus. -fn catalog_entry(title: &str, stem: &str, summary: Option<&str>) -> String { - let head = format!("### {title}\n\nFull doc: `code-ranker docs {stem}`"); - match summary { - Some(s) => format!("{head}\n\n{s}"), - None => head, - } +/// One catalog entry on a SINGLE line: a `### <title>` heading plus the +/// language-scoped `docs <lang> <stem>` pointer to the full doc. Deliberately +/// compact (no TL;DR summary) so the AI playbook's catalog stays scannable — +/// one line per principle / metric. +fn catalog_entry(title: &str, lang: &str, stem: &str) -> String { + format!("### {title} Full doc: `code-ranker docs {lang} {stem}`") } /// Build the catalog the `<!-- doc:tldr-index -->` marker expands to: every -/// `base/<ID>.md` (except `AI.md` itself), alphabetical, each as a `### <title>` -/// heading + a `--doc <ID>` pointer to the full doc + its one-paragraph summary. -fn tldr_index() -> String { +/// `base/<ID>.md` (except `AI.md` itself), alphabetical, one line each (see +/// [`catalog_entry`]). `lang` is the resolved plugin so every pointer reads +/// `code-ranker docs <lang> <ID>` (docs are per-language). +fn tldr_index(lang: &str) -> String { let mut entries: Vec<(String, String)> = CORPUS .iter() .filter_map(|(rel, contents)| { @@ -206,7 +171,7 @@ fn tldr_index() -> String { .find_map(|l| l.strip_prefix("# ")) .unwrap_or(stem) .trim(); - let entry = catalog_entry(title, stem, doc_summary(contents).as_deref()); + let entry = catalog_entry(title, lang, stem); Some((stem.to_ascii_lowercase(), entry)) }) .collect(); @@ -215,17 +180,17 @@ fn tldr_index() -> String { .into_iter() .map(|(_, e)| e) .collect::<Vec<_>>() - .join("\n\n") + .join("\n") } /// Replace a `<!-- doc:tldr-index -->` marker with the generated catalog; a no-op /// for docs that don't carry it. Also drops the `base/AI.md` *Select a language* /// section ([`strip_select_section`]) — it is the `ai` command's unresolved-only -/// template and must never appear in a served doc (`--doc AI`, `ai` when resolved). -fn expand_tldr_index(md: &str) -> String { +/// template and must never appear in a served doc (`docs <lang> AI`, `ai` when resolved). +fn expand_tldr_index(md: &str, lang: &str) -> String { let md = strip_select_section(md); if md.contains(TLDR_INDEX_MARKER) { - md.replace(TLDR_INDEX_MARKER, &tldr_index()) + md.replace(TLDR_INDEX_MARKER, &tldr_index(lang)) } else { md } @@ -253,21 +218,22 @@ pub(crate) fn resolve_doc_from_specs( templates: &TemplatesConfig, id: &str, ) -> Result<String> { - Ok(expand_tldr_index(&resolve_doc_raw( - principles, - node_attributes, - templates, - id, - )?)) + // The tldr-index marker only ever lives in `base/AI.md` (served via `ai_doc`), + // so a single-doc resolution never actually expands it; `base` is an inert + // fallback language for the (unreachable here) marker case. + Ok(expand_tldr_index( + &resolve_doc_raw(principles, node_attributes, templates, id)?, + "base", + )) } /// The offline AI-agent overview (`base/AI.md`) with its catalog index expanded — /// served straight from the embedded corpus with **no snapshot, no project /// analysis, and no plugin detection**. Backs the `docs ai` subcommand, so the /// playbook prints in any directory regardless of language markers. -pub(crate) fn ai_doc() -> Result<String> { +pub(crate) fn ai_doc(lang: &str) -> Result<String> { let md = corpus_doc("base/AI.md").context("base/AI.md is not embedded in this build")?; - Ok(expand_tldr_index(md)) + Ok(expand_tldr_index(md, lang)) } /// The brief intro from `base/AI.md` for the `ai` command's unresolved mode: diff --git a/crates/code-ranker-cli/src/templates_test.rs b/crates/code-ranker-cli/src/templates_test.rs index f5d89035..808a5d10 100644 --- a/crates/code-ranker-cli/src/templates_test.rs +++ b/crates/code-ranker-cli/src/templates_test.rs @@ -285,10 +285,9 @@ fn resolve_doc_ai_index_expands_tldr_marker() { "catalog lists ADP" ); assert!( - doc.contains("Full doc: `code-ranker docs ADP`"), - "each entry points at its --doc id" + doc.contains("Full doc: `code-ranker docs base ADP`"), + "each entry points at its language-scoped docs id" ); - assert!(doc.contains("**TL;DR**"), "entries carry their TL;DR"); assert!( !doc.contains("### code-ranker — AI agent skill"), "AI.md excludes itself from its own index" @@ -299,7 +298,9 @@ fn resolve_doc_ai_index_expands_tldr_marker() { fn ai_doc_matches_resolve_doc_and_needs_no_snapshot() { // `ai_doc()` backs the project-free `ai` subcommand: it must produce exactly // what `docs AI` does, but without a snapshot or plugin. - let doc = ai_doc().unwrap(); + // `base` matches the inert language `resolve_doc_from_specs` uses, so the two + // paths produce byte-identical output. + let doc = ai_doc("base").unwrap(); let via_resolve = resolve_doc( &snap(vec![], BTreeMap::new()), &TemplatesConfig::default(), @@ -388,38 +389,18 @@ fn resolve_doc_resolves_base_doc_by_filename_stem() { } #[test] -fn doc_summary_prefers_tldr_then_first_paragraph() { - let with_tldr = "# T\n\n**TL;DR**: line one\nline two\n\n## Next\nbody"; - assert_eq!( - doc_summary(with_tldr).as_deref(), - Some("**TL;DR**: line one line two") - ); - let no_tldr = "# T\n\nFirst prose paragraph.\nstill it.\n\n## Next"; - assert_eq!( - doc_summary(no_tldr).as_deref(), - Some("First prose paragraph. still it.") - ); -} +fn catalog_entry_is_one_line_with_language_scoped_pointer() { + // One line per entry: `### <title> Full doc: `code-ranker docs <lang> <stem>``, + // no TL;DR summary, and the pointer carries the resolved language. + let e = catalog_entry("Henry–Kafura", "js", "HK"); + assert_eq!(e, "### Henry–Kafura Full doc: `code-ranker docs js HK`"); + assert!(!e.contains('\n'), "single line: {e}"); -#[test] -fn catalog_entry_includes_summary_when_present_and_omits_when_absent() { - let with = catalog_entry("Henry–Kafura", "HK", Some("A coupling metric.")); - assert!( - with.starts_with("### Henry–Kafura"), - "heading first: {with}" - ); - assert!( - with.contains("Full doc: `code-ranker docs HK`"), - "carries the --doc pointer: {with}" - ); - assert!( - with.ends_with("A coupling metric."), - "summary appended: {with}" + // `base` (the language-agnostic catalog) flows through verbatim. + assert_eq!( + catalog_entry("Edge Case", "base", "EC"), + "### Edge Case Full doc: `code-ranker docs base EC`" ); - - // No summary → heading + pointer only, no trailing paragraph (the `None` arm). - let without = catalog_entry("Edge Case", "EC", None); - assert_eq!(without, "### Edge Case\n\nFull doc: `code-ranker docs EC`"); } #[test] diff --git a/crates/code-ranker-graph/metrics/builtin.toml b/crates/code-ranker-graph/metrics/builtin.toml index 5af0e7d5..c22878f5 100644 --- a/crates/code-ranker-graph/metrics/builtin.toml +++ b/crates/code-ranker-graph/metrics/builtin.toml @@ -373,12 +373,12 @@ description = "Cycle kind this node participates in." [cycles.mutual] label = "Mutual" description = "Two units import each other (A ↔ B), so neither can be built, tested, or understood in isolation — the tightest possible coupling." -remediation = "Run `code-ranker docs ADP` and follow its instructions." +remediation = "Run `code-ranker docs {lang} ADP` and follow its instructions." [cycles.chain] label = "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." -remediation = "Run `code-ranker docs ADP` and follow its instructions." +remediation = "Run `code-ranker docs {lang} ADP` and follow its instructions." # ── prompt scaffolding ──────────────────────────────────────────────────────── # The Prompt-Generator framing prose moved OUT of this file into `metrics/prompt.md` diff --git a/crates/code-ranker-graph/metrics/prompt.md b/crates/code-ranker-graph/metrics/prompt.md index 17796469..ffd26e44 100644 --- a/crates/code-ranker-graph/metrics/prompt.md +++ b/crates/code-ranker-graph/metrics/prompt.md @@ -5,9 +5,11 @@ parsed into `PromptTemplate` by `prompt_template()` and carried in the snapshot the CLI `prompt` format and the HTML viewer render the same text from one source. Each `## <field>` section maps to a `PromptTemplate` field; `## task` is a list (one entry per bullet, kept verbatim — the leading `- ` is part of the rendered -line). `{id}` in a `task` or `doc_note` line is substituted with the active principle -id at render time (e.g. `--doc {id}` → `--doc HK`). This is internal template prose, -not a published corpus doc — it lives next to `builtin.toml`, not under `languages/`. +line). In a `task` or `doc_note` line, `{id}` is substituted with the active +principle/metric id and `{lang}` with the resolved language at render time (e.g. +`code-ranker docs {lang} {id}` → `code-ranker docs rust HK`). This is internal +template prose, not a published corpus doc — it lives next to `builtin.toml`, not +under `languages/`. ## intro @@ -15,7 +17,7 @@ I want to apply this to some modules in my system. ## 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. +**First, before reading the source**, run `code-ranker docs {lang} {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. ## task diff --git a/crates/code-ranker-graph/src/builtin_test.rs b/crates/code-ranker-graph/src/builtin_test.rs index 19679a9e..1a32e1b4 100644 --- a/crates/code-ranker-graph/src/builtin_test.rs +++ b/crates/code-ranker-graph/src/builtin_test.rs @@ -12,8 +12,8 @@ fn prompt_template_parses_from_markdown() { "I want to apply this to some modules in my system." ); assert!( - t.doc_note.contains("`code-ranker docs {id}`"), - "doc_note points at the offline --doc command: {:?}", + t.doc_note.contains("`code-ranker docs {lang} {id}`"), + "doc_note points at the offline per-language docs command: {:?}", t.doc_note ); // `## task` keeps one entry per bullet, verbatim (the leading `- ` stays). diff --git a/crates/code-ranker-plugins/src/languages/c/mod.rs b/crates/code-ranker-plugins/src/languages/c/mod.rs index eaa9a6e2..04bda1df 100644 --- a/crates/code-ranker-plugins/src/languages/c/mod.rs +++ b/crates/code-ranker-plugins/src/languages/c/mod.rs @@ -50,7 +50,12 @@ impl LanguagePlugin for CPlugin { 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)) + cfamily::detect( + workspace, + &c, + input.ignore_tests, + &crate::walk::ignore_from(input), + ) } fn levels(&self, cfg: &toml::Table) -> Vec<Level> { 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 e06f4b03..8e8ea543 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 @@ -734,7 +734,7 @@ ], "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.", + "doc_note": "**First, before reading the source**, run `code-ranker docs {lang} {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": [ diff --git a/crates/code-ranker-plugins/src/languages/cfamily/mod.rs b/crates/code-ranker-plugins/src/languages/cfamily/mod.rs index 384ab9bf..97870fe7 100644 --- a/crates/code-ranker-plugins/src/languages/cfamily/mod.rs +++ b/crates/code-ranker-plugins/src/languages/cfamily/mod.rs @@ -72,8 +72,12 @@ pub fn is_test_path(rel_path: &str, cfg: &Cfg) -> bool { /// True when any source file with one of `cfg.extensions` exists under /// `workspace` (used by `detect` — C/C++ have no universal manifest file). -pub fn detect(workspace: &Path, cfg: &Cfg, ignore: &IgnoreCfg) -> bool { - !collect_files(workspace, cfg, false, ignore).is_empty() +/// Honors `ignore_tests` so detection sees the SAME files analysis will: a +/// project whose only C/C++ sources are test fixtures (skipped when +/// `[ignore] tests` is on) is not auto-detected, avoiding a "produced no nodes" +/// warning for a language that has nothing to analyze. +pub fn detect(workspace: &Path, cfg: &Cfg, ignore_tests: bool, ignore: &IgnoreCfg) -> bool { + !collect_files(workspace, cfg, ignore_tests, ignore).is_empty() } fn collect_files( diff --git a/crates/code-ranker-plugins/src/languages/cpp/mod.rs b/crates/code-ranker-plugins/src/languages/cpp/mod.rs index e24b1322..c27b0921 100644 --- a/crates/code-ranker-plugins/src/languages/cpp/mod.rs +++ b/crates/code-ranker-plugins/src/languages/cpp/mod.rs @@ -50,7 +50,12 @@ impl LanguagePlugin for CppPlugin { 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)) + cfamily::detect( + workspace, + &c, + input.ignore_tests, + &crate::walk::ignore_from(input), + ) } fn levels(&self, cfg: &toml::Table) -> Vec<Level> { 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 4a9d9ac4..cdbef010 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 @@ -744,7 +744,7 @@ ], "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.", + "doc_note": "**First, before reading the source**, run `code-ranker docs {lang} {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": [ diff --git a/crates/code-ranker-plugins/src/languages/csharp/mod.rs b/crates/code-ranker-plugins/src/languages/csharp/mod.rs index c5fe798d..fb513177 100644 --- a/crates/code-ranker-plugins/src/languages/csharp/mod.rs +++ b/crates/code-ranker-plugins/src/languages/csharp/mod.rs @@ -41,7 +41,11 @@ impl LanguagePlugin for CsharpPlugin { } fn detect(&self, _cfg: &toml::Table, workspace: &Path, input: &PluginInput) -> bool { - structure::detect(workspace, &crate::walk::ignore_from(input)) + structure::detect( + workspace, + input.ignore_tests, + &crate::walk::ignore_from(input), + ) } fn levels(&self, cfg: &toml::Table) -> Vec<Level> { diff --git a/crates/code-ranker-plugins/src/languages/csharp/structure.rs b/crates/code-ranker-plugins/src/languages/csharp/structure.rs index 3974e103..631dbb1f 100644 --- a/crates/code-ranker-plugins/src/languages/csharp/structure.rs +++ b/crates/code-ranker-plugins/src/languages/csharp/structure.rs @@ -73,8 +73,14 @@ pub(super) fn is_test_path(rel_path: &str) -> bool { .any(|s| file.ends_with(s.as_str())) } -pub(super) fn detect(workspace: &Path, ignore: &crate::config::IgnoreCfg) -> bool { - !collect_files(workspace, false, ignore).is_empty() +pub(super) fn detect( + workspace: &Path, + ignore_tests: bool, + ignore: &crate::config::IgnoreCfg, +) -> bool { + // Honor `ignore_tests` so a project whose only `.cs` files are test fixtures + // (skipped by analysis) is not auto-detected and then warned about as empty. + !collect_files(workspace, ignore_tests, ignore).is_empty() } fn collect_files( 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 a656c538..c38ecfae 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 @@ -680,7 +680,7 @@ ], "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.", + "doc_note": "**First, before reading the source**, run `code-ranker docs {lang} {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": [ 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 f8cefadd..032ea705 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 @@ -682,7 +682,7 @@ ], "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.", + "doc_note": "**First, before reading the source**, run `code-ranker docs {lang} {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": [ 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 index b1b60a4b..e08eeaaa 100644 --- 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 @@ -38,12 +38,12 @@ "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." + "remediation": "Run `code-ranker docs {lang} 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." + "remediation": "Run `code-ranker docs {lang} ADP` and follow its instructions." } }, "cycles": [ @@ -924,7 +924,7 @@ ], "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.", + "doc_note": "**First, before reading the source**, run `code-ranker docs {lang} {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": [ 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 index 4da0fe42..b29f0692 100644 --- 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 @@ -206,7 +206,7 @@ }, "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.", + "doc_note": "**First, before reading the source**, run `code-ranker docs {lang} {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": [ 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 d393d15c..af53d77b 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 @@ -38,12 +38,12 @@ "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." + "remediation": "Run `code-ranker docs {lang} 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." + "remediation": "Run `code-ranker docs {lang} ADP` and follow its instructions." } }, "cycles": [ @@ -1033,7 +1033,7 @@ ], "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.", + "doc_note": "**First, before reading the source**, run `code-ranker docs {lang} {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": [ 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 0bdb5b45..647e2613 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 @@ -42,12 +42,12 @@ "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." + "remediation": "Run `code-ranker docs {lang} 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." + "remediation": "Run `code-ranker docs {lang} ADP` and follow its instructions." } }, "cycles": [ @@ -1733,7 +1733,7 @@ ], "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.", + "doc_note": "**First, before reading the source**, run `code-ranker docs {lang} {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": [ 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 index ebb5989c..5e99b623 100644 --- 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 @@ -38,12 +38,12 @@ "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." + "remediation": "Run `code-ranker docs {lang} 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." + "remediation": "Run `code-ranker docs {lang} ADP` and follow its instructions." } }, "cycles": [ @@ -991,7 +991,7 @@ ], "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.", + "doc_note": "**First, before reading the source**, run `code-ranker docs {lang} {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": [ diff --git a/crates/code-ranker-viewer/src/assets/export-popup.js b/crates/code-ranker-viewer/src/assets/export-popup.js index 410b472a..25a0c9fe 100644 --- a/crates/code-ranker-viewer/src/assets/export-popup.js +++ b/crates/code-ranker-viewer/src/assets/export-popup.js @@ -161,7 +161,7 @@ function openExportPopup(level, restore) { // Wrap a principle's title + prompt into the full instruction the AI receives: // intent, the summary, how to read the full principle (the offline - // `code-ranker report --doc <id>` command — no network URL), and a + // `code-ranker docs <lang> <id>` command — no network URL), and a // research/report protocol (report violations in the modules below, save the // report to `.code-ranker/<timestamp>-<id>.md`). const composePrompt = id => { @@ -182,12 +182,16 @@ function openExportPopup(level, restore) { '', ]; // A doc exists (signalled by `doc_url`): point the agent at the offline - // `--doc <id>` command rather than a network URL. + // `code-ranker docs <lang> <id>` command rather than a network URL. `{lang}` + // is the active language so the command is runnable as-is (docs are per-language). + const lang = (typeof currentLang === 'function' && currentLang()) + || Object.keys(window.DIFF || {})[0] || 'base'; + const fill = s => (s || '').replaceAll('{id}', id).replaceAll('{lang}', lang); if (url) { - lines.push((t.doc_note || '').replaceAll('{id}', id), ''); + lines.push(fill(t.doc_note), ''); } lines.push('## Task', ''); - for (const line of (t.task || [])) lines.push(line.replaceAll('{id}', id)); + for (const line of (t.task || [])) lines.push(fill(line)); lines.push('', t.focus || ''); return lines.join('\n'); }; @@ -340,7 +344,7 @@ function openExportPopup(level, restore) { return (vr != null && vr !== 0) ? `- \`${path(n)}\` (${label}: ${vr})` : `- \`${path(n)}\``; }).join('\n'); // A single target reads as one module, not a ranking. The formula is - // dropped (it lives in `--doc <id>`); the description is skipped when it + // dropped (it lives in `docs <lang> <id>`); the description is skipped when it // already appears verbatim as the Summary above (the metric lens). const heading = activeNodes.length === 1 ? `## Target module (${label})` : `## Modules ordered by ${label}`; const principlePrompt = snapshotPrinciples().find(p => p.id === activePrincipleKey)?.prompt; diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 3dd7e1de..3732be18 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -962,6 +962,11 @@ 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. +`detect` runs against the same `PluginInput` analysis uses, so marker-less, +extension-detected plugins (`c`/`cpp`/`csharp`/`markdown`) walk with `[ignore] tests` +applied: a project whose only matching files are test fixtures is not auto-detected, +keeping detection consistent with what analysis would actually process. + **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 diff --git a/docs/ai-skill.md b/docs/ai-skill.md index 6fd33d58..b9e89be8 100644 --- a/docs/ai-skill.md +++ b/docs/ai-skill.md @@ -61,26 +61,26 @@ One thing per pass, worst-first. ```sh # 1. Find what to fix. The gate verdict: -code-ranker check . +code-ranker check . --plugins <lang> # …or focus one metric or principle in the triage (cycle = ADP, then hk, sloc, cognitive, …): -code-ranker report . --output.scorecard --focus cycle --top 1 +code-ranker report . --plugins <lang> --output.scorecard --focus cycle --top 1 # 2. Get the actionable fix-prompt for a named principle (pick it from the scorecard): -code-ranker report . --prompt cycle --top 1 +code-ranker report . --plugins <lang> --prompt cycle --top 1 # …or get a focused fix-prompt directly (metric- or principle-framed): -code-ranker report . --prompt hk --top 1 +code-ranker report . --plugins <lang> --prompt hk --top 1 # 3. Review it; propose the fix to the user and get agreement. # 4. Snapshot the BEFORE state: -code-ranker report . --output.json.path=.code-ranker/before.json +code-ranker report . --plugins <lang> --output.json.path=.code-ranker/before.json # 5. Apply the fix. # 6. Run all tests. # 7. Render the before/after report and open it: -code-ranker report . --baseline .code-ranker/before.json \ +code-ranker report . --plugins <lang> --baseline .code-ranker/before.json \ --output.json.path=.code-ranker/after.json \ --output.html.path=.code-ranker/after.html open .code-ranker/after.html # macOS; xdg-open on Linux @@ -125,10 +125,10 @@ threshold mismatch. ## Cheat sheet ```sh -code-ranker report . --output.scorecard # triage: all principles -code-ranker report . --output.scorecard --focus hk --top 1 # focus one metric or principle -code-ranker report . --prompt hk --top 1 # LLM fix-prompt for a named principle/metric -code-ranker check . --baseline base.json --output-format json # CI regression verdict +code-ranker report . --plugins <lang> --output.scorecard # triage: all principles +code-ranker report . --plugins <lang> --output.scorecard --focus hk --top 1 # focus one metric or principle +code-ranker report . --plugins <lang> --prompt hk --top 1 # LLM fix-prompt for a named principle/metric +code-ranker check . --plugins <lang> --baseline base.json --output-format json # CI regression verdict ``` ## Gotchas diff --git a/docs/code-ranker-cli/CLI.md b/docs/code-ranker-cli/CLI.md index b8c20c2d..bd3326a0 100644 --- a/docs/code-ranker-cli/CLI.md +++ b/docs/code-ranker-cli/CLI.md @@ -355,7 +355,7 @@ code-ranker report . --output.scorecard code-ranker report . --output.scorecard --focus hk --top 5 # AI fix-prompt for a named principle/metric, to stdout -code-ranker report . --prompt hk --top 1 +code-ranker report . --plugins <lang> --prompt hk --top 1 ``` The HTML is **self-contained**: the snapshot data is embedded inline, so the single file @@ -549,13 +549,18 @@ WORST MODULES 2 warn snapshot.rs sloc 1.8K +hk 3 info plugin/rust.rs fan_out 14 -→ code-ranker report . --prompt <PRINCIPLE|METRIC> +→ code-ranker report . --plugins <lang> --prompt <PRINCIPLE|METRIC> ``` `--top N` caps the worst-modules list (default ~15); `--focus <NAME>` narrows the scorecard to a single ranking metric (or frames it by a principle); `--focus-path <PATH>` scopes the ranked modules to a subtree. +In `--focus <metric>` mode the worst-modules list is ranked by that metric, and each +row's tier reflects **that metric's own threshold**: `warn` over the warning line, +`info` over the info line, `—` when the value is under both (ranked, but not a breach) +or the metric has no configured threshold. + ### `--prompt <ID>` — AI fix-prompt for one principle/metric by name `--prompt <ID>` prints the fix-prompt for the principle or metric you name to stdout and @@ -574,8 +579,8 @@ frames the prompt by the **metric itself** — its own name, description, and `r doc (e.g. `plugins/base/HK.md`), with **no** SOLID design-principle wrapper. ```sh -code-ranker report . --prompt HK --top 1 # HK fix-prompt, top module -code-ranker report . --prompt HK > prompt.md # redirect to a file when you need an artifact +code-ranker report . --plugins <lang> --prompt HK --top 1 # HK fix-prompt, top module +code-ranker report . --plugins <lang> --prompt HK > prompt.md # redirect to a file when you need an artifact ``` To print a **reference doc** itself (a principle's text, a metric's spec card, the AI @@ -709,9 +714,15 @@ default markers are: - `pyproject.toml` / `setup.py` / `setup.cfg` → `python` - `package.json` / `tsconfig.json` → `javascript` +`c` / `cpp` / `csharp` / `markdown` have no marker file — they are detected by the +**presence of a source file** with one of their extensions. That file walk honours +`[ignore] tests` (and `.gitignore` / hidden rules), so detection sees the same files +analysis will: a project whose only such files are test fixtures (skipped when +`[ignore] tests` is on) is **not** auto-detected. + **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. +there is no "ambiguous project" error. A language that still yields an empty graph +is dropped with a `produced no nodes` warning. **Invariant: one file ↔ exactly one language.** The active plugins' file sets are disjoint. diff --git a/docs/code-ranker-cli/USE-CASES.md b/docs/code-ranker-cli/USE-CASES.md index ba59d1f8..f6a7f788 100644 --- a/docs/code-ranker-cli/USE-CASES.md +++ b/docs/code-ranker-cli/USE-CASES.md @@ -102,7 +102,7 @@ code-ranker report . --baseline .code-ranker/before.json --output.html.path=.cod **Get a copy-paste AI fix-prompt for a named principle/metric (pick it from the scorecard).** ```sh -code-ranker report . --prompt HK --top 1 +code-ranker report . --plugins <lang> --prompt HK --top 1 ``` --- @@ -306,13 +306,13 @@ prompt is printed to stdout. **Emit the fix-prompt to stdout (for an agent to read on a failed gate).** ```sh -code-ranker report . --prompt HK --top 1 +code-ranker report . --plugins <lang> --prompt HK --top 1 ``` **Save the fix-prompt to a file (redirect stdout).** ```sh -code-ranker report . --prompt HK > prompt.md +code-ranker report . --plugins <lang> --prompt HK > prompt.md ``` --- diff --git a/docs/customization/README.md b/docs/customization/README.md index 899138bb..be031ceb 100644 --- a/docs/customization/README.md +++ b/docs/customization/README.md @@ -322,7 +322,7 @@ the **same id** as a plugin principle overrides it; a new id appends. Run it: code-ranker report . --config code-ranker.toml --output.scorecard --focus tsr --severity warning --top 1 # or generate the refactoring prompt for that metric: -code-ranker report . --config code-ranker.toml --prompt tsr --top 1 +code-ranker report . --plugins <lang> --config code-ranker.toml --prompt tsr --top 1 ``` For the `--severity` counts to be meaningful the metric should carry `warning` / diff --git a/docs/templates.md b/docs/templates.md index 5cfcbde3..17e6574d 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -193,10 +193,10 @@ write it to **stdout**, then exit — no HTML/JSON artifacts. ```bash # "give me the HK prompt" → prints it immediately -code-ranker report . --prompt HK +code-ranker report . --plugins <lang> --prompt HK # narrow the ranked modules it lists -code-ranker report . --prompt HK --top 5 --focus-path src/engine +code-ranker report . --plugins <lang> --prompt HK --top 5 --focus-path src/engine ``` - `<ID>` is a principle id (`HK`, `ADP`, `SRP`, …) or a metric key; unknown ids fail @@ -208,7 +208,7 @@ code-ranker report . --prompt HK --top 5 --focus-path src/engine link. - It is the explicit, name-it-yourself, print-to-stdout path — the quick "show me HK" path — and (being a standalone dump) accepts any `--top N` to widen the ranked module - list. Redirect to a file when you need an artifact: `code-ranker report . --prompt HK > prompt.md`. + list. Redirect to a file when you need an artifact: `code-ranker report . --plugins <lang> --prompt HK > prompt.md`. ### 7.2 `docs <lang> <subject>` — print the raw principle doc ✅ @@ -250,8 +250,8 @@ corpus, `prompt.md` is **internal template prose**: it sits next to `builtin.tom | Field | Role | |---|---| | `intro` | one-line intent under the title | -| `doc_note` | how to read the full principle — points at the offline `code-ranker docs <lang> <id>` command (`{id}` substituted), not a network URL | -| `task` | the task-protocol bullets (`{id}` → active principle id) | +| `doc_note` | how to read the full principle — points at the offline `code-ranker docs <lang> <id>` command (`{id}` → active principle/metric id, `{lang}` → resolved language), not a network URL | +| `task` | the task-protocol bullets (`{id}` → active principle id, `{lang}` → resolved language) | | `focus` | closing emphasis line | | `cycle_note` | note prepended to a single dependency-cycle's module list | diff --git a/plugins/base/AI.md b/plugins/base/AI.md index 51d39f8f..53ee6172 100644 --- a/plugins/base/AI.md +++ b/plugins/base/AI.md @@ -19,11 +19,13 @@ This is the short guide for driving it — the commands below operate the tool. - **`report [input]`** — produces **artifacts**: a JSON snapshot, a self-contained HTML viewer, and the advisory **`scorecard`** (console triage) / **`prompt`** (an LLM fix-prompt). Always exits `0` — the analysis + refactoring entry point. -- **`docs <subject>`** — print a reference doc to stdout (no analysis). `docs ai` - prints this playbook (with a language plugin resolved it appends the full - principle/metric catalog); `docs metrics` / `docs principles` index every metric / - principle; `docs <category>` (`loc`, `complexity`, …) lists a category; `docs <ID>` - prints one metric or principle (`docs hk`, `docs SRP`). Always exits `0`. +- **`docs <lang> <subject>`** — print a reference doc to stdout (no analysis). The + language comes first (`rust`, `python`, …, or `base` for the language-agnostic + catalog). `docs <lang> ai` prints this playbook plus the full principle/metric + catalog; `docs <lang> metrics` / `docs <lang> principles` index every metric / + principle; `docs <lang> <category>` (`loc`, `complexity`, …) lists a category; + `docs <lang> <ID>` prints one metric or principle (`docs rust hk`, + `docs rust SRP`). Always exits `0`. - **`help`** — usage for the binary or any command (`code-ranker --help`, `code-ranker <command> --help`, or `-h <command>`). Lists every flag. @@ -43,7 +45,7 @@ Pick one of: **{plugins}**. Either name it per run (applies to `check` / `report too): ```sh -code-ranker check . --plugin <name> +code-ranker check . --plugins <name> ``` …or set it once in a `code-ranker.toml` at the project root, so every command picks @@ -51,10 +53,11 @@ it up: ```toml version = "{config_version}" -plugin = "<name>" +[plugins] +enabled = ["<name>"] ``` -Then re-run `code-ranker docs ai` for the full playbook and the principle/metric catalog. +Then re-run `code-ranker docs <name> ai` for the full playbook and the principle/metric catalog. <!-- ai:select-end --> ## The two that matter most @@ -70,12 +73,12 @@ inspect the worst tier with `--severity warning`. ## The fix loop ```sh -code-ranker check . # 1. the gate verdict -code-ranker report . --output.scorecard --focus ADP --top 1 # 2. focus one metric/principle, worst-first -code-ranker docs <principle> # 3. READ the deep doc — before you touch code +code-ranker check . --plugins <lang> # 1. the gate verdict +code-ranker report . --plugins <lang> --output.scorecard --focus ADP --top 1 # 2. focus one metric/principle, worst-first +code-ranker docs <lang> <principle> # 3. READ the deep doc — before you touch code ``` -**Step 3 is not optional — read the `docs <principle>` page before proposing a +**Step 3 is not optional — read the `docs <lang> <principle>` page before proposing a fix.** It names the *language-specific cause* of this violation and the *smallest correct remedy* for it, often with a worked example. Agents that skip it reach for a heavier, wrong-shaped refactor that can leave the real cycle intact, introduce a new @@ -87,7 +90,7 @@ principle, by that design principle. ## Principles & metrics -Each entry summarizes one principle or metric; run `code-ranker docs <ID>` +Each entry summarizes one principle or metric; run `code-ranker docs <lang> <ID>` to print its full doc (offline, straight to the terminal). <!-- doc:tldr-index --> diff --git a/plugins/base/HK.md b/plugins/base/HK.md index 4fd9acb0..6168e8b3 100644 --- a/plugins/base/HK.md +++ b/plugins/base/HK.md @@ -124,7 +124,7 @@ code-dependency (`uses`) edges that drive fan_in/fan_out. No `jq`, no snapshot t query: ```bash -code-ranker report --prompt HK --top 1 +code-ranker report --plugins <lang> --prompt HK --top 1 ``` The connection list says *which* modules couple; it does not say *why*. For that, From 9bb520c833bd8bac63e30914d5f45172c63a2997 Mon Sep 17 00:00:00 2001 From: Roman Fedorov <Roman.Fedorov@constructor.tech> Date: Mon, 29 Jun 2026 00:33:43 +0300 Subject: [PATCH 2/3] =?UTF-8?q?fix(docs):=20address=20CodeRabbit=20review?= =?UTF-8?q?=20=E2=80=94=20pin=20BEFORE=20snapshot,=20keep=20base=20hints?= =?UTF-8?q?=20generic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - contrib: the BEFORE-snapshot step pinned `--plugins <lang>` (was bare `report .`, could auto-detect the wrong language on a multi-language repo). - docs index hints (`docs <lang> metrics/principles/<category>`) print a concrete language for a real plugin but keep the generic `<lang>` for `base`, matching `localize_lang` (via a small `command_lang` helper). Claude-Session: https://claude.ai/code/session_01Jcdvq3iTsqk74KfrXxZzcP --- contrib/prompting-self-improve.md | 2 +- crates/code-ranker-cli/src/docs.rs | 10 ++++++++++ crates/code-ranker-cli/src/docs_test.rs | 18 ++++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/contrib/prompting-self-improve.md b/contrib/prompting-self-improve.md index 6a3aaac9..42bf6f3c 100644 --- a/contrib/prompting-self-improve.md +++ b/contrib/prompting-self-improve.md @@ -154,7 +154,7 @@ nothing eval-related is left in `PROJECT`. `code-ranker docs <lang> ai` (overview + catalog) and `docs <lang> <FOCUS>` (the deep doc). This is what a real user would do, so it tests the *prompt*, not your coaching. -3. **BEFORE.** `code-ranker report . --output.html.path=$RUN/before.html --output.json.path=$RUN/before.json`. +3. **BEFORE.** `code-ranker report . --plugins <lang> --output.html.path=$RUN/before.html --output.json.path=$RUN/before.json`. 4. **Save the focused prompt** (orchestrator, for the record): `code-ranker report . --plugins <lang> --prompt <FOCUS> > $RUN/prompt.md` — captures the exact fix-prompt this run used into `$RUN/prompt.md`, so prompt ↔ diff --git a/crates/code-ranker-cli/src/docs.rs b/crates/code-ranker-cli/src/docs.rs index 3f361b95..7f575f2e 100644 --- a/crates/code-ranker-cli/src/docs.rs +++ b/crates/code-ranker-cli/src/docs.rs @@ -136,6 +136,13 @@ fn localize_lang(md: String, lang: &str) -> String { } } +/// The language token a `docs` command hint should print: a concrete language for a +/// real plugin, but the generic `<lang>` placeholder for `base` (its catalog is +/// language-agnostic — mirrors [`localize_lang`]). +fn command_lang(lang: &str) -> &str { + if lang == "base" { "<lang>" } else { lang } +} + /// `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) @@ -414,6 +421,7 @@ fn principles_block(specs: &DocSpecs) -> String { /// `docs <lang> metrics`: every metric, grouped by category. fn render_metrics_index(specs: &DocSpecs, lang: &str) -> String { + let lang = command_lang(lang); format!( "Metrics — print one with `code-ranker docs {lang} <metric>`:\n{}", categories_block(specs) @@ -422,6 +430,7 @@ fn render_metrics_index(specs: &DocSpecs, lang: &str) -> String { /// `docs <lang> principles`: every design principle. fn render_principles_index(specs: &DocSpecs, lang: &str) -> String { + let lang = command_lang(lang); format!( "Principles — print one with `code-ranker docs {lang} <ID>`:\n\n{}", principles_block(specs) @@ -430,6 +439,7 @@ fn render_principles_index(specs: &DocSpecs, lang: &str) -> String { /// `docs <lang> <category>`: the category's human label + description + its member metrics. fn render_category(specs: &DocSpecs, lang: &str, key: &str) -> String { + let lang = command_lang(lang); // Single-category view: the human label is the title (the key was just typed), // so there is no `key: Label` echo. let mut out = category_label(specs, key); diff --git a/crates/code-ranker-cli/src/docs_test.rs b/crates/code-ranker-cli/src/docs_test.rs index 21556c48..900f1099 100644 --- a/crates/code-ranker-cli/src/docs_test.rs +++ b/crates/code-ranker-cli/src/docs_test.rs @@ -134,6 +134,24 @@ fn principles_index_lists_each_principle() { assert!(out.contains("- TSR: Test Ratio"), "principle listed: {out}"); } +/// Index hints name a concrete language, but keep the generic `<lang>` placeholder +/// for `base` (its catalog is language-agnostic — mirrors `localize_lang`). +#[test] +fn index_hints_keep_generic_lang_for_base() { + assert!( + render_metrics_index(&specs(), "rust").contains("`code-ranker docs rust <metric>`"), + "concrete language for a real plugin" + ); + assert!( + render_metrics_index(&specs(), "base").contains("`code-ranker docs <lang> <metric>`"), + "base keeps the generic placeholder" + ); + assert!( + render_principles_index(&specs(), "base").contains("`code-ranker docs <lang> <ID>`"), + "principles index too" + ); +} + #[test] fn principles_block_reports_when_the_plugin_defines_none() { let mut s = specs(); From 90be37cfd0d24d8cf0147a0c69d606a8707131b4 Mon Sep 17 00:00:00 2001 From: Roman Fedorov <Roman.Fedorov@constructor.tech> Date: Mon, 29 Jun 2026 00:42:55 +0300 Subject: [PATCH 3/3] =?UTF-8?q?fix(docs):=20keep=20docs.rs=20under=20the?= =?UTF-8?q?=20SLOC=20gate=20=E2=80=94=20localize=20<lang>=20instead=20of?= =?UTF-8?q?=20command=5Flang?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CodeRabbit fix added a `command_lang` helper (+6 SLOC) that pushed docs.rs to 403 > the repo's own 400 sloc gate, failing the dogfood self-check in CI. Drop the helper: index hints now carry the literal `<lang>` token and are localized once at emit time via `localize_lang` (concrete language for a real plugin, generic for `base`) — same behavior, one substitution point, back under the gate. Claude-Session: https://claude.ai/code/session_01Jcdvq3iTsqk74KfrXxZzcP --- crates/code-ranker-cli/src/docs.rs | 37 ++++++++++--------------- crates/code-ranker-cli/src/docs_test.rs | 27 ++++++++---------- 2 files changed, 26 insertions(+), 38 deletions(-) diff --git a/crates/code-ranker-cli/src/docs.rs b/crates/code-ranker-cli/src/docs.rs index 7f575f2e..e42b5756 100644 --- a/crates/code-ranker-cli/src/docs.rs +++ b/crates/code-ranker-cli/src/docs.rs @@ -92,11 +92,11 @@ pub(crate) fn run( // so `fan_in`, `Fan-in`, and `FAN in` all resolve the same metric. let want = templates::normalize_id(subject); if want == "metrics" { - emit(render_metrics_index(&specs, language), language); + emit(render_metrics_index(&specs), language); } else if want == "principles" { - emit(render_principles_index(&specs, language), language); + emit(render_principles_index(&specs), language); } else if let Some(cat) = category_key(&specs, subject) { - emit(render_category(&specs, language, &cat), language); + emit(render_category(&specs, &cat), language); } else if let Some(p) = specs .principles .iter() @@ -127,7 +127,9 @@ fn emit(md: String, lang: &str) { /// Make instructional `<lang>` placeholders concrete in served per-language docs, so /// commands print runnable as-is (`docs rust hk`, `--plugins rust`). `base` is the -/// language-agnostic catalog, so its generic `<lang>` stays a placeholder. +/// language-agnostic catalog, so its generic `<lang>` stays a placeholder. Every +/// `docs`-command hint is written with the literal `<lang>` token and localized +/// here at emit time — one substitution point for the whole served doc. fn localize_lang(md: String, lang: &str) -> String { if lang == "base" { md @@ -136,13 +138,6 @@ fn localize_lang(md: String, lang: &str) -> String { } } -/// The language token a `docs` command hint should print: a concrete language for a -/// real plugin, but the generic `<lang>` placeholder for `base` (its catalog is -/// language-agnostic — mirrors [`localize_lang`]). -fn command_lang(lang: &str) -> &str { - if lang == "base" { "<lang>" } else { lang } -} - /// `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) @@ -419,36 +414,32 @@ fn principles_block(specs: &DocSpecs) -> String { .collect() } -/// `docs <lang> metrics`: every metric, grouped by category. -fn render_metrics_index(specs: &DocSpecs, lang: &str) -> String { - let lang = command_lang(lang); +/// `docs <lang> metrics`: every metric, grouped by category. The `<lang>` hint is +/// localized at emit time (concrete language, or kept generic for `base`). +fn render_metrics_index(specs: &DocSpecs) -> String { format!( - "Metrics — print one with `code-ranker docs {lang} <metric>`:\n{}", + "Metrics — print one with `code-ranker docs <lang> <metric>`:\n{}", categories_block(specs) ) } /// `docs <lang> principles`: every design principle. -fn render_principles_index(specs: &DocSpecs, lang: &str) -> String { - let lang = command_lang(lang); +fn render_principles_index(specs: &DocSpecs) -> String { format!( - "Principles — print one with `code-ranker docs {lang} <ID>`:\n\n{}", + "Principles — print one with `code-ranker docs <lang> <ID>`:\n\n{}", principles_block(specs) ) } /// `docs <lang> <category>`: the category's human label + description + its member metrics. -fn render_category(specs: &DocSpecs, lang: &str, key: &str) -> String { - let lang = command_lang(lang); +fn render_category(specs: &DocSpecs, key: &str) -> String { // Single-category view: the human label is the title (the key was just typed), // so there is no `key: Label` echo. let mut out = category_label(specs, key); if let Some(d) = specs.groups.get(key).and_then(|g| g.description.as_deref()) { out.push_str(&format!("\n{d}")); } - out.push_str(&format!( - "\n\nMetrics — print one with `code-ranker docs {lang} <metric>`:\n" - )); + out.push_str("\n\nMetrics — print one with `code-ranker docs <lang> <metric>`:\n"); for (k, spec) in metrics_in_category(specs, key) { out.push_str(&format!(" - {k}: {}", metric_name(spec, k))); if let Some(d) = spec.description.as_deref() { diff --git a/crates/code-ranker-cli/src/docs_test.rs b/crates/code-ranker-cli/src/docs_test.rs index 900f1099..48f1a65c 100644 --- a/crates/code-ranker-cli/src/docs_test.rs +++ b/crates/code-ranker-cli/src/docs_test.rs @@ -54,7 +54,7 @@ fn category_subject_resolves_case_insensitively() { #[test] fn render_category_lists_label_description_and_members() { - let out = render_category(&specs(), "rust", "loc"); + let out = render_category(&specs(), "loc"); assert!(out.contains("Lines of Code"), "header (human label): {out}"); assert!( out.contains("Lines of code breakdown"), @@ -120,7 +120,7 @@ fn catalog_lists_every_subject_class() { #[test] fn metrics_index_lists_categories_and_members() { - let out = render_metrics_index(&specs(), "rust"); + let out = render_metrics_index(&specs()); assert!( out.contains("loc — Lines of code breakdown"), "category: {out}" @@ -130,25 +130,22 @@ fn metrics_index_lists_categories_and_members() { #[test] fn principles_index_lists_each_principle() { - let out = render_principles_index(&specs(), "rust"); + let out = render_principles_index(&specs()); assert!(out.contains("- TSR: Test Ratio"), "principle listed: {out}"); } -/// Index hints name a concrete language, but keep the generic `<lang>` placeholder -/// for `base` (its catalog is language-agnostic — mirrors `localize_lang`). +/// Index hints carry the generic `<lang>` token; `emit` → `localize_lang` makes it +/// concrete for a real plugin and keeps `<lang>` for `base` (covered by +/// `localize_lang_substitutes_concrete_language_but_not_base`). #[test] -fn index_hints_keep_generic_lang_for_base() { +fn index_hints_use_generic_lang_placeholder() { assert!( - render_metrics_index(&specs(), "rust").contains("`code-ranker docs rust <metric>`"), - "concrete language for a real plugin" + render_metrics_index(&specs()).contains("`code-ranker docs <lang> <metric>`"), + "metrics index hint" ); assert!( - render_metrics_index(&specs(), "base").contains("`code-ranker docs <lang> <metric>`"), - "base keeps the generic placeholder" - ); - assert!( - render_principles_index(&specs(), "base").contains("`code-ranker docs <lang> <ID>`"), - "principles index too" + render_principles_index(&specs()).contains("`code-ranker docs <lang> <ID>`"), + "principles index hint" ); } @@ -156,7 +153,7 @@ fn index_hints_keep_generic_lang_for_base() { fn principles_block_reports_when_the_plugin_defines_none() { let mut s = specs(); s.principles.clear(); - let out = render_principles_index(&s, "rust"); + let out = render_principles_index(&s); assert!(out.contains("(none"), "empty principles note: {out}"); }