From 255a545f9303b8e7ccb7c0b79269de9992b65266 Mon Sep 17 00:00:00 2001 From: Roman Fedorov Date: Mon, 22 Jun 2026 23:23:19 +0300 Subject: [PATCH 01/40] fix(viewer): map hover flicker, navigation, and modifier/colour fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Map interaction fixes surfaced while dogfooding the report: - Flicker: drop the opacity transition on edges. Animating opacity on hundreds of edge path/polygon elements promoted each to its own compositor layer for the tween and flickered the whole page (worse the more elements there are). Snapping is a single repaint. Confirmed via a performance trace (7408 opacity Animation events on edges). - Hover de-flicker: ignore the synthetic re-mouseenter that raisePaint's reparent fires on the same node; ignore a cluster mouseleave while the pointer is still inside the cluster bbox (crossing sibling nodes/edges painted over the folder background no longer toggles the highlight). - Navigation: clicking a single-file box (files-level, key == node id) opens the file modal instead of drilling into a degenerate single-file group (which also leaked a {target}-prefixed id into the breadcrumb); Shift-click now selects file-node boxes in the overview too. - Open-source modifier: Alt/Option on every platform (was Cmd on macOS / Ctrl elsewhere — Cmd clashed with copy/paste, Ctrl is right-click on macOS); rename the cursor class .ctrl-link -> .src-link. - Crate cluster at dig>0 rendered neutral grey (red/pink reserved for the crate overview at dig 0). - Layout: .svg-frame min-height 420px; zoom buttons and the shortcut legend lifted clear of the bottom status bar; tier-menu options share one style (drop the active .on accent). Docs synced: docs/code-ranker-viewer/{PRD,DESIGN}.md. --- .../code-ranker-viewer/src/assets/layout.js | 5 +- .../src/assets/map-interactions.js | 77 ++++++++++++++++--- .../code-ranker-viewer/src/assets/map-svg.css | 40 ++++++---- crates/code-ranker-viewer/src/assets/map.css | 6 +- .../code-ranker-viewer/src/assets/modal.css | 3 +- docs/code-ranker-viewer/DESIGN.md | 8 +- docs/code-ranker-viewer/PRD.md | 16 ++-- 7 files changed, 113 insertions(+), 42 deletions(-) diff --git a/crates/code-ranker-viewer/src/assets/layout.js b/crates/code-ranker-viewer/src/assets/layout.js index 4245cb55..7968e1f5 100644 --- a/crates/code-ranker-viewer/src/assets/layout.js +++ b/crates/code-ranker-viewer/src/assets/layout.js @@ -210,7 +210,10 @@ function buildDOT(nodes, edges, level, viewport) { let ci = 0; for (const [crate, entries] of byCrate) { dot += ` subgraph cluster_crate_${ci++} {\n`; - dot += ` label=${dotId(crate)} style=filled fillcolor="#fff2f2" color="#e3b3b3" fontname="Helvetica" fontsize=11 fontcolor="#a05a5a"\n`; + // Red signals "crate" only at the overview (dig 0). Once dug in (dig>0) the + // crate is just a container around its folders — render it neutral, matching + // the folder sub-clusters, so the red is reserved for the top-level boxes. + dot += ` label=${dotId(crate)} style=filled fillcolor="#f7f7f7" color="#cccccc" fontname="Helvetica" fontsize=11 fontcolor="#666666"\n`; for (const [g, gNodes] of entries) dot += ` ${groupBoxDot(g, gNodes)}\n`; dot += ' }\n'; } diff --git a/crates/code-ranker-viewer/src/assets/map-interactions.js b/crates/code-ranker-viewer/src/assets/map-interactions.js index deb6d544..143ceb2a 100644 --- a/crates/code-ranker-viewer/src/assets/map-interactions.js +++ b/crates/code-ranker-viewer/src/assets/map-interactions.js @@ -27,13 +27,13 @@ function toggleNodeSelected(node, level, section) { section?._updateAllCb?.(); } -// The "open source" modifier is platform-specific: ⌘ (Meta) on macOS — where -// Ctrl is deliberately left alone (it maps to right-click) — and Ctrl elsewhere. +// The "open source" modifier is Alt/Option (⌥) on every platform — chosen over ⌘ +// (which clashes with copy/paste on macOS) and Ctrl (which is right-click on macOS). const IS_MAC = /Mac|iP(hone|ad|od)/.test( (typeof navigator !== 'undefined' && (navigator.platform || navigator.userAgent)) || '' ); -const OPEN_SRC_KEY = IS_MAC ? 'Meta' : 'Control'; -const isOpenSrcClick = e => (IS_MAC ? e.metaKey : e.ctrlKey); +const OPEN_SRC_KEY = 'Alt'; +const isOpenSrcClick = e => e.altKey; // Exposed on window so modal.js (the popup diagram) can mirror the gesture — // `const` declarations are not auto-attached to the global object. window.isOpenSrcClick = isOpenSrcClick; @@ -41,7 +41,7 @@ window.isOpenSrcClick = isOpenSrcClick; // Shortcut-legend markup with the platform's actual keys; reused by the main map // (`#kbd-hints`) and the popup (`#node-modal-hints`, filled in modal.js). function kbdHintsHtml() { - const srcKey = IS_MAC ? '⌘' : 'Ctrl'; + const srcKey = IS_MAC ? '⌥ Option' : 'Alt'; return `⇧ Shift + click — select node` + `${srcKey} + click — view source` + `t — toggle baseline/current`; @@ -51,10 +51,10 @@ window.kbdHintsHtml = kbdHintsHtml; // Map modifier modes, each changing the cursor (see the CSS) and rerouting node // clicks (see the click handler in setupTooltips): // • Shift (`.shift-select`) — toggle a node's selection instead of the modal; -// • ⌘ (mac) / Ctrl (`.ctrl-link`) — open the node's source on the git host. +// • Alt/Option ⌥ (`.src-link`) — open the node's source on the git host. (function initMapModifiers() { const setShift = on => document.body.classList.toggle('shift-select', on); - const setSrc = on => document.body.classList.toggle('ctrl-link', on); + const setSrc = on => document.body.classList.toggle('src-link', on); // Fill the bottom-left shortcut legend with the platform's actual keys. const hints = document.getElementById('kbd-hints'); @@ -337,6 +337,16 @@ function switchTier(tier, level) { window.switchTier = switchTier; function drillIntoGroup(groupId, level, dig) { + // A files-dig "group" key is the node's own id (`{target}/…/file.ext`) — not a + // folder. Folder keys are always {target}-stripped & filename-less, so an exact + // match against a real node id means this box is a single file: open it instead + // of drilling into a degenerate single-file group. Drilling would set drillGroup + // to the {target}-prefixed id and feed it to the folder-keyed breadcrumb, where + // every path chip mismatches → literal `{target}` chip + 0 counts everywhere. + if ((unionGraph(level).nodes || []).some(n => n.id === groupId)) { + if (window.openModalForNode?.(groupId, level)) window.navPush?.(level, groupId); + return; + } window.drillGroup = groupId; // The drilled view filters by the grouper that produced this group key, so // remember the dig it came from — caller may override (a crate cluster drills @@ -768,6 +778,10 @@ const HOVER_DELAY = 70; // ms before a hover effect applies — avoids flicker function wireNodeHover(el, onEnter, onLeave) { let timer = null, active = false; el.addEventListener('mouseenter', () => { + // Already settled on this node? A fresh mouseenter without a mouseleave is the + // synthetic re-fire from raisePaint's reparent — ignore it (avoids re-running + // onEnter + a redundant paint-raise, the source of the first-hover flicker). + if (active) return; if (timer) clearTimeout(timer); timer = setTimeout(() => { timer = null; active = true; @@ -945,8 +959,18 @@ function setupEdgeHighlight(svgFrame, level) { clusterEl.addEventListener('mouseenter', () => ehSchedule(() => { applyHighlight(edges); showSB(statusText); setShowInOut(isIn, isOut); })); - clusterEl.addEventListener('mouseleave', () => - ehSchedule(() => { clearHighlight(); hideSB(); setShowInOut(false, false); })); + clusterEl.addEventListener('mouseleave', e => { + // Nodes & edges are painted as SIBLINGS on TOP of the cluster background, so + // dragging the pointer across one of them — while still inside the folder — + // fires a spurious `mouseleave`. Clearing on it then re-applying on the + // re-enter toggled the highlight of all 574 edges on/off, repainting the whole + // map (and visibly the page) — the flicker. A leaf node has nothing painted + // over it, so files never flickered. Ignore a leave whose pointer is still + // within the cluster's bounding box (a real exit lands outside it). + const r = clusterEl.getBoundingClientRect(); + if (e.clientX >= r.left && e.clientX < r.right && e.clientY >= r.top && e.clientY < r.bottom) return; + ehSchedule(() => { clearHighlight(); hideSB(); setShowInOut(false, false); }); + }); } // ── IN/OUT edges are always hidden by default; revealed on cluster/node hover ── @@ -963,13 +987,21 @@ function setupEdgeHighlight(svgFrame, level) { const nodeId = nodeEl.querySelector('title')?.textContent?.trim(); if (!nodeId) continue; + // `raisePaint` (first hover) reparents the node via appendChild, which makes the + // browser re-fire `mouseenter` on the SAME node with no intervening `mouseleave` + // — that re-runs the whole edge-highlight pass and flickers the map. Track the + // hovered state and swallow the synthetic, redundant re-enter. + let entered = false; nodeEl.addEventListener('mouseenter', () => { + if (entered) return; + entered = true; // Status bar is updated by setupTooltips handlers (fire after these). A node // is a leaf (file / collapsed box) → reveal its dashed non-flow edges. ehSchedule(() => { applyHighlight(edgeMap.get(nodeId) ?? new Set(), true); setShowInOut(false, false); }); }); nodeEl.addEventListener('mouseleave', e => { + entered = false; // Moving back onto a cluster background re-applies that cluster's full state // (highlight + in/out reveal); otherwise clear. All via the shared debounce. const destCluster = e.relatedTarget?.closest?.('g.cluster'); @@ -1097,6 +1129,33 @@ function setupTooltips(svgFrame, level) { const groupId = titleEl?.textContent?.trim(); titleEl?.remove(); if (!groupId) return; + + // A files-dig box is a single real file node (key === its id), not a folder + // group. Wire it exactly like the drilled file view so the map stays + // consistent: Shift toggles selection (and highlights the box — which needs it + // registered in gNodeMap), the open-source modifier opens the source, a plain + // click opens the modal. Folder/crate group boxes fall through to drill below. + const fileNode = (unionGraph(level).nodes || []).find(n => n.id === groupId); + if (fileNode) { + g.dataset.nodeId = groupId; + gNodeMap.set(groupId, g); + if (window._ntSelected?.[level]?.has(groupId)) g.classList.add('node-selected'); + g.addEventListener('click', e => { + e.stopPropagation(); + if (isOpenSrcClick(e)) { + const url = nodeSourceUrl(fileNode, level); + if (url) window.open(url, '_blank', 'noopener'); + return; + } + if (e.shiftKey) { toggleNodeSelected(fileNode, level, section); return; } + if (window.openModalForNode?.(groupId, level)) window.navPush?.(level, groupId); + }); + wireNodeHover(g, + () => { showStatus(statusLineFor(fileNode, level)); window.fanHighlightFile?.(true, groupId); }, + e => { window.fanHighlightFile?.(false, groupId); if (!e.relatedTarget?.closest?.('g.cluster')) hideStatus(); }); + return; + } + const stats = groupStats.get(groupId); if (!stats) return; diff --git a/crates/code-ranker-viewer/src/assets/map-svg.css b/crates/code-ranker-viewer/src/assets/map-svg.css index 340865d9..cb57e253 100644 --- a/crates/code-ranker-viewer/src/assets/map-svg.css +++ b/crates/code-ranker-viewer/src/assets/map-svg.css @@ -73,44 +73,48 @@ g.node polygon, g.node ellipse { stroke-width: 1; } body.shift-select .svg-frame svg { cursor: crosshair; } body.shift-select .svg-frame g.node { cursor: copy; } -/* ── Ctrl/⌘ = "open source" on the main map: cursor signals click-to-open ───── */ -body.ctrl-link .svg-frame svg { cursor: default; } -body.ctrl-link .svg-frame g.node { cursor: alias; } +/* ── Alt/⌥ = "open source" on the main map: cursor signals click-to-open ────── */ +body.src-link .svg-frame svg { cursor: default; } +body.src-link .svg-frame g.node { cursor: alias; } /* Same modifier cursors inside the per-node popup diagram; 3rd-party (`.diag-ext`) cards are inert (not selectable, no source) so they show `not-allowed`. */ body.shift-select #node-modal-diagram g[data-diag-node]:not(.diag-ext) { cursor: copy; } -body.ctrl-link #node-modal-diagram g[data-diag-node]:not(.diag-ext) { cursor: alias; } +body.src-link #node-modal-diagram g[data-diag-node]:not(.diag-ext) { cursor: alias; } body.shift-select #node-modal-diagram g.diag-ext, -body.ctrl-link #node-modal-diagram g.diag-ext { cursor: not-allowed; } +body.src-link #node-modal-diagram g.diag-ext { cursor: not-allowed; } /* The central (main) card reacts to modifiers too (copy/view-source); external main cards are inert. */ body.shift-select #node-modal-diagram .mn-card:not(.diag-ext) { cursor: copy; } -body.ctrl-link #node-modal-diagram .mn-card:not(.diag-ext) { cursor: alias; } +body.src-link #node-modal-diagram .mn-card:not(.diag-ext) { cursor: alias; } body.shift-select #node-modal-diagram .mn-card.diag-ext, -body.ctrl-link #node-modal-diagram .mn-card.diag-ext { cursor: not-allowed; } +body.src-link #node-modal-diagram .mn-card.diag-ext { cursor: not-allowed; } /* Popup's own shortcut legend (bottom-left of the fullscreen modal), shown while a modifier is held — the map's legend is hidden behind the popup. */ #node-modal-hints { z-index: 5; } body.shift-select #node-modal-hints, -body.ctrl-link #node-modal-hints { opacity: 1; } +body.src-link #node-modal-hints { opacity: 1; } -/* While a modifier is held, a Shift/⌘-click on a card must not drag-select the +/* While a modifier is held, a Shift/⌥-click on a card must not drag-select the SVG/label text (without a modifier, normal text selection is left intact). */ -body.shift-select, body.ctrl-link { user-select: none; -webkit-user-select: none; } +body.shift-select, body.src-link { user-select: none; -webkit-user-select: none; } /* While a map hotkey is held, reveal the same right-side controls (zoom + size) that a hover into the right edge shows — visual cue that a mode is active. */ body.shift-select .frame-wrap .zoom-controls, -body.ctrl-link .frame-wrap .zoom-controls, +body.src-link .frame-wrap .zoom-controls, body.shift-select .frame-wrap .size-controls, -body.ctrl-link .frame-wrap .size-controls { opacity: 1; pointer-events: auto; } +body.src-link .frame-wrap .size-controls { opacity: 1; pointer-events: auto; } /* ── Shortcut legend (bottom-left): shown on the same hover/hotkey as controls ── */ .kbd-hints { position: absolute; bottom: 12px; left: 12px; z-index: 10; display: flex; gap: 8px; opacity: 0; transition: opacity .15s; pointer-events: none; } +/* Homepage (single-snapshot) view: lift the legend clear of the bottom status bar + (~22px tall) — it shows on the same hover that surfaces a node's status line, so + the two would otherwise stack at bottom:12px and the bar (z-index 15) would win. */ +body.mode-review .kbd-hints { bottom: 34px; } .kbd-hint { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; color: #5c7a96; background: rgba(255,255,255,.95); border: 1px solid #d0dcea; border-radius: 6px; padding: 3px 8px; } @@ -119,7 +123,7 @@ body.ctrl-link .frame-wrap .size-controls { opacity: 1; pointer-events: auto; border: 1px solid #cdd7e2; border-radius: 4px; padding: 0 5px; } .frame-wrap.show-zoom .kbd-hints, body.shift-select .frame-wrap .kbd-hints, -body.ctrl-link .frame-wrap .kbd-hints { opacity: 1; } +body.src-link .frame-wrap .kbd-hints { opacity: 1; } /* ── Selection: persistent yellow highlight ─────────────────────────────────── */ .node-table tr.row-selected td { background: rgb(254,245,222); } @@ -159,10 +163,12 @@ g.node.node-hl { filter: drop-shadow(0 0 7px rgba(30,160,220,1)) drop-shadow(0 0 /* The bar is toggled with the `hidden` attribute; no extra display rule needed. */ /* ── SVG edge hover / cluster edge visibility ─────────────────────────────── */ -/* Base transition so all opacity changes animate smoothly */ -.svg-frame g.edge > path, -.svg-frame g.edge > polygon, -.svg-frame g.edge > text { transition: opacity 0.12s; } +/* NO opacity transition on edges. A hover toggles the opacity of hundreds of edge + path/polygon/text elements at once; animating them made Chrome promote each to + its own compositor layer for the ~0.12s tween and immediately discard it — a + layer-churn storm that flickered the whole page (worse the more elements there + are). Snapping opacity (no transition) is a single repaint — no churn, no flicker. + Confirmed via a performance trace: 7408 opacity Animation events on edges. */ /* Cluster IN/OUT edges (>10): hidden until cluster zone is hovered */ .svg-frame g.edge.cluster-edge-hidden > path, diff --git a/crates/code-ranker-viewer/src/assets/map.css b/crates/code-ranker-viewer/src/assets/map.css index 2680e7ee..bc1533d1 100644 --- a/crates/code-ranker-viewer/src/assets/map.css +++ b/crates/code-ranker-viewer/src/assets/map.css @@ -3,14 +3,16 @@ .frame-wrap { position: relative; margin-bottom: 10px; } .svg-frame { background: #fff; border: 1px solid #ddd; border-radius: 6px; overflow: hidden; user-select: none; - height: calc(100vh - 240px); min-height: 200px; } + height: calc(100vh - 240px); min-height: 420px; } .svg-frame svg { width: 100%; height: 100%; display: block; cursor: grab; } /* Drag-to-pan, GitHub-style: open hand at rest, closed hand while dragging (the closed hand wins over a node's `pointer` cursor for the whole drag). */ .svg-frame.panning svg { cursor: grabbing; } .svg-frame.panning svg g.node { cursor: grabbing; } -.zoom-controls { position: absolute; bottom: 12px; right: 12px; +/* bottom: lifted clear of the ~22px-tall status bar (`.svg-status-bar`, bottom:0) + that surfaces on node/cluster hover, so the tooltip never overlaps the buttons. */ +.zoom-controls { position: absolute; bottom: 34px; right: 12px; display: flex; flex-direction: column; gap: 5px; opacity: 0; transition: opacity .15s; pointer-events: none; } .frame-wrap.show-zoom .zoom-controls { opacity: 1; pointer-events: auto; } diff --git a/crates/code-ranker-viewer/src/assets/modal.css b/crates/code-ranker-viewer/src/assets/modal.css index bc56548c..7988105f 100644 --- a/crates/code-ranker-viewer/src/assets/modal.css +++ b/crates/code-ranker-viewer/src/assets/modal.css @@ -129,7 +129,8 @@ text.sn-hint, tspan.sn-hint, g.sn-hint { cursor: inherit; } .tier-opt { background: none; border: none; cursor: pointer; text-align: left; font-size: 12px; color: #444; padding: 5px 10px; line-height: 1; } .tier-opt:hover { background: #f0f0f0; } -.tier-opt.on { font-weight: 700; color: #1abc9c; } +/* All tier options share one style/colour — the active tier is not visually + distinguished in the dropdown (no bold/accent on .on). */ /* Reveal-depth lens chip: ⊟ depth N/max ⊞ at the end of the breadcrumb. */ .crumb-lens { display: inline-flex; align-items: flex-start; gap: 6px; diff --git a/docs/code-ranker-viewer/DESIGN.md b/docs/code-ranker-viewer/DESIGN.md index 0b3b0cb3..9dc88d07 100644 --- a/docs/code-ranker-viewer/DESIGN.md +++ b/docs/code-ranker-viewer/DESIGN.md @@ -73,21 +73,21 @@ top-to-bottom). The viewer was split out of three former monoliths (`diagram.js` | File | Purpose | |------|---------| -| `layout.js` | `buildDOT()` — emits the DOT for the map. Overview groups nodes by `grouperForDig` at `window.dig` (one box per group, deduped inter-group flow edges); each group box is labelled `fullPath (memberCount)` and tagged `cycle-status-*` aggregated from its members. Box fill: pink at the crate tier, white otherwise; metric **circles are always filled** — red at the crate tier, blue (`N_FILL`) otherwise (never empty white). At `dig > 0` with crate grouping the folder-group boxes are wrapped in a labelled **crate cluster** (`subgraph cluster_crate_N`, faint-pink) so folders read as inside their crate; the file tier and flat/coarse views stay flat. At the **files level** (`isFilesDig`) the overview instead emits one `subgraph cluster_files_N` per **deepest single folder** (no nested folders), each holding its actual **file nodes** (`fileDot` — file name / metric circle, blue `N_FILL`), with internal edges remapped file↔file; external nodes stay as their plural group box. The drilled (focus) view filters to the focused group (`grouperForDig(level, drillDig)` === `drillGroup`) and renders a **hybrid** at reveal depth `D` (derived from `window.focusDig`): a node whose folder level under the focus is ≤ `D` becomes an individual **file** node (label = file **name** only) inside its dir sub-cluster (grouped by `nodeFullDir`, **labelled relative to the focus** via `stripDirPrefix`, faint-filled, hoverable/clickable to drill); a deeper node collapses into a **folder box** keyed at the frontier `groupKeyAtDig(level, n, drillDig + D + 1)` (in a metric size-mode this folder is drawn as a grey **circle** sized by the aggregate metric of the files it hides, so every node is a sized circle — overview groups, revealed files and collapsed folders alike). So depth 0 shows the focus's direct files in their dir cluster alongside its immediate subfolders as boxes; `⊞` opens one more level (edges remapped file→box via `renderId`). The neighbour **Fan-in** (callers) / **Fan-out** (dependencies) sections are **not** emitted in the DOT — the internal graph lays out alone, so a +/− collapse can never reflow it. `buildDOT` instead computes the neighbour **crates** (`crateOf` — the other end's crate regardless of tier; the focus's **own** crate included, surfacing intra-crate coupling; both `uses` flow and `contains`/`reexports` non-flow cross-boundary edges count) and stashes `window._fanData` — per crate: the distinct *flow*-coupled-file count for the `crate (N)` label (`(0)`/dashed when only non-flow links it), plus our render-ids the arrows attach to, each with its `flow` flag and diff `status`. The sections + their real arrows are drawn onto the SVG afterwards by **`composeFanSections`** (map-interactions.js — see **Fan-in / Fan-out sections** below). **Flow** edges are drawn solid and counted (neighbour discovery and the overview metric edges still guard on `edgeIsFlow`); **non-flow** `contains`/`reexports` edges are also emitted now — **dashed**, `constraint=false`, tagged `edge-nonflow` and **hidden by CSS until a leaf-node hover** reveals the connected ones — only an individual file or a collapsed folder/group box (frame gets `.leaf-hovered`), **not** a directory sub-cluster that already shows its files (a pair already flow-linked is skipped). They also appear in the popup. No `ratio=fill`/`size` (natural layout, packed spacing); edges carry `arrowsize=0.6` (smaller arrowheads, which otherwise read oversized once the viewBox scales up on sparse graphs). The **node filter** (`window.nodeFilter`, a key from `ui.filter`) drops every node where the active filter metric has no signal — `cycle` is special-cased to the cycle-membership set (keeping the edges between cycle nodes and the callers/dependencies clusters); any other key keeps nodes with a non-zero value. Circle **sizing** is generic over the active `ui.size` key (`window.nodeSizeMode` = the attribute key, e.g. `sloc`/`hk` or a custom metric): `metricNodeDiam`/`metricGroupDiam` scale the area by `sqrt(v/base)` where built-in `sloc`/`hk` keep calibrated bases and any other metric uses the median of the rendered population's positive values (`metricSizeBase`, cached per render in `window._sizeBaseCache`). | +| `layout.js` | `buildDOT()` — emits the DOT for the map. Overview groups nodes by `grouperForDig` at `window.dig` (one box per group, deduped inter-group flow edges); each group box is labelled `fullPath (memberCount)` and tagged `cycle-status-*` aggregated from its members. Box fill: pink at the crate tier, white otherwise; metric **circles are always filled** — red at the crate tier, blue (`N_FILL`) otherwise (never empty white). At `dig > 0` with crate grouping the folder-group boxes are wrapped in a labelled **crate cluster** (`subgraph cluster_crate_N`, **neutral grey** — matching the folder sub-clusters; the red/pink is reserved for crates at the overview/dig 0, so once dug in the crate reads as a plain container) so folders read as inside their crate; the file tier and flat/coarse views stay flat. At the **files level** (`isFilesDig`) the overview instead emits one `subgraph cluster_files_N` per **deepest single folder** (no nested folders), each holding its actual **file nodes** (`fileDot` — file name / metric circle, blue `N_FILL`), with internal edges remapped file↔file; external nodes stay as their plural group box. The drilled (focus) view filters to the focused group (`grouperForDig(level, drillDig)` === `drillGroup`) and renders a **hybrid** at reveal depth `D` (derived from `window.focusDig`): a node whose folder level under the focus is ≤ `D` becomes an individual **file** node (label = file **name** only) inside its dir sub-cluster (grouped by `nodeFullDir`, **labelled relative to the focus** via `stripDirPrefix`, faint-filled, hoverable/clickable to drill); a deeper node collapses into a **folder box** keyed at the frontier `groupKeyAtDig(level, n, drillDig + D + 1)` (in a metric size-mode this folder is drawn as a grey **circle** sized by the aggregate metric of the files it hides, so every node is a sized circle — overview groups, revealed files and collapsed folders alike). So depth 0 shows the focus's direct files in their dir cluster alongside its immediate subfolders as boxes; `⊞` opens one more level (edges remapped file→box via `renderId`). The neighbour **Fan-in** (callers) / **Fan-out** (dependencies) sections are **not** emitted in the DOT — the internal graph lays out alone, so a +/− collapse can never reflow it. `buildDOT` instead computes the neighbour **crates** (`crateOf` — the other end's crate regardless of tier; the focus's **own** crate included, surfacing intra-crate coupling; both `uses` flow and `contains`/`reexports` non-flow cross-boundary edges count) and stashes `window._fanData` — per crate: the distinct *flow*-coupled-file count for the `crate (N)` label (`(0)`/dashed when only non-flow links it), plus our render-ids the arrows attach to, each with its `flow` flag and diff `status`. The sections + their real arrows are drawn onto the SVG afterwards by **`composeFanSections`** (map-interactions.js — see **Fan-in / Fan-out sections** below). **Flow** edges are drawn solid and counted (neighbour discovery and the overview metric edges still guard on `edgeIsFlow`); **non-flow** `contains`/`reexports` edges are also emitted now — **dashed**, `constraint=false`, tagged `edge-nonflow` and **hidden by CSS until a leaf-node hover** reveals the connected ones — only an individual file or a collapsed folder/group box (frame gets `.leaf-hovered`), **not** a directory sub-cluster that already shows its files (a pair already flow-linked is skipped). They also appear in the popup. No `ratio=fill`/`size` (natural layout, packed spacing); edges carry `arrowsize=0.6` (smaller arrowheads, which otherwise read oversized once the viewBox scales up on sparse graphs). The **node filter** (`window.nodeFilter`, a key from `ui.filter`) drops every node where the active filter metric has no signal — `cycle` is special-cased to the cycle-membership set (keeping the edges between cycle nodes and the callers/dependencies clusters); any other key keeps nodes with a non-zero value. Circle **sizing** is generic over the active `ui.size` key (`window.nodeSizeMode` = the attribute key, e.g. `sloc`/`hk` or a custom metric): `metricNodeDiam`/`metricGroupDiam` scale the area by `sqrt(v/base)` where built-in `sloc`/`hk` keep calibrated bases and any other metric uses the median of the rendered population's positive values (`metricSizeBase`, cached per render in `window._sizeBaseCache`). | | `map-render.js` | `drawSVG()` (big-graph confirm guard, drilled views only — counts the **rendered** element count `focusRenderCount` at the current depth, i.e. files + collapsed boxes, not the raw file count, so a deep-but-collapsed focus doesn't over-warn) and `renderSVGNow()` (DOT→SVG via `window.gv`, then wires pan/zoom, the status bar, edge-highlight and tooltips). | ### Map interactions | File | Purpose | |------|---------| -| `map-interactions.js` | All behaviour on the main SVG map: node selection + the platform open-source modifier (`isOpenSrcClick`, ⌘/Ctrl), the shortcut legend (`kbdHintsHtml`), **drill** nav (`drillIntoGroup(key, level, dig)`/`drillOutOfGroup`) driving the always-visible **breadcrumb** (`renderBreadcrumb`): a **tier-dropdown anchor** (`tierAnchorHtml` + `handleTierToggle` — shared with the modal header; its label opens a crates ⇄ files menu → `switchTier`, which maps the focus across dimensions; shown only when the level has crates), a **root element** (`all`/`root`, drills out to the overview, replacing the old static "← all"), clickable path chips that each drill to themselves, and a trailing **reveal-depth lens chip** `⊟ depth N ⊞`. Per-chip hover counts: files under each chip; the crate/file total under the root. **Reveal depth** (`setDig` ±1 → `window.dig` in the overview, `window.focusDig` while focused): `lensInfo` computes the displayed depth (offset from `minFz` in focus / `overviewBaseDig` in the overview) plus the ⊟/⊞ enabled state and hover previews (`focusRenderCount` in focus — files + collapsed boxes — / `groupCountAtDig` in the overview); `focusMinFz`/`focusMaxDepth`/`underDepthOf` derive the focus bounds, `landingFocusDig` the drill-in landing depth (deepest reveal under `FOCUS_NODE_BUDGET` nodes), and `overviewBaseDig` the overview landing. The lens replaces the former overview `.dig-lod` and last-crumb `−/+` controls. Clicking a **box body** (a folder box, a directory sub-cluster, or a crate cluster) drills the focus into it (`focusFolderTarget` → key + dig). The status bar (`computeGroupStats` → `statusLineFor`/`statusLineForGroup`: group/neighbour lines carry the full path, **folders** count and files/sloc/hk/cycle, the `_root` collapse sentinel shown as `/`; hovering a caller/dependency neighbour box shows the same crate-style stats), `setupEdgeHighlight(svgFrame, level)` (must run **before** `setupTooltips`, which removes SVG ``s; the **Fan-in/out overlay** is composed here (`composeFanSections` — see **Fan-in / Fan-out sections** below); `cluster_crate_*` overview clusters highlight all edges of the groups inside them; an expanded cluster's **background is inert** (the folder/crate is already open) — only its **name/path label** (`<text>`) navigates: **clicking a crate cluster's label drills into the whole crate** (`drillIntoGroup(label, level, 0)`), a drilled **directory sub-cluster**'s label drills into that folder — its representative node found by focus-relative dir match (`nodeRelDir` = `stripDirPrefix`∘`nodeFullDir`), so it stays clickable regardless of SVG nesting (the guard is `if (!e.target.closest('text')) return`); a **neighbour crate box** click drills into that crate's folder (`crateFocusTarget`)), `setupTooltips`. **Hover is debounced + de-flickered** (`HOVER_DELAY`): `wireNodeHover` delays the glow/raise so quick passes don't flash and clears every other `node-hl` first (never two glows at once); `raisePaint` lifts the hovered node to the end of its SVG parent (paint order — SVG has no z-index); a single shared `ehSchedule` timer drives **all** edge-highlight changes (nodes and clusters) so crossing boundaries never flashes the arrows. | +| `map-interactions.js` | All behaviour on the main SVG map: node selection + the open-source modifier (`isOpenSrcClick` — **Alt/Option ⌥ on every platform**; chosen over ⌘, which clashed with copy/paste on macOS, and Ctrl, which is right-click there; the held-modifier cursor class is `.src-link`), the shortcut legend (`kbdHintsHtml`), **drill** nav (`drillIntoGroup(key, level, dig)`/`drillOutOfGroup`) driving the always-visible **breadcrumb** (`renderBreadcrumb`): a **tier-dropdown anchor** (`tierAnchorHtml` + `handleTierToggle` — shared with the modal header; its label opens a crates ⇄ files menu → `switchTier`, which maps the focus across dimensions; shown only when the level has crates), a **root element** (`all`/`root`, drills out to the overview, replacing the old static "← all"), clickable path chips that each drill to themselves, and a trailing **reveal-depth lens chip** `⊟ depth N ⊞`. Per-chip hover counts: files under each chip; the crate/file total under the root. **Reveal depth** (`setDig` ±1 → `window.dig` in the overview, `window.focusDig` while focused): `lensInfo` computes the displayed depth (offset from `minFz` in focus / `overviewBaseDig` in the overview) plus the ⊟/⊞ enabled state and hover previews (`focusRenderCount` in focus — files + collapsed boxes — / `groupCountAtDig` in the overview); `focusMinFz`/`focusMaxDepth`/`underDepthOf` derive the focus bounds, `landingFocusDig` the drill-in landing depth (deepest reveal under `FOCUS_NODE_BUDGET` nodes), and `overviewBaseDig` the overview landing. The lens replaces the former overview `.dig-lod` and last-crumb `−/+` controls. Clicking a **box body** (a folder box, a directory sub-cluster, or a crate cluster) drills the focus into it (`focusFolderTarget` → key + dig); a box that is itself a single **file** (its key is a real node id — the files level) instead behaves like a file node — a plain click opens its modal, Shift toggles selection, the open-source modifier opens its source (`drillIntoGroup` short-circuits a node-id key to `openModalForNode`, so a degenerate single-file "group" never lands in the breadcrumb). The status bar (`computeGroupStats` → `statusLineFor`/`statusLineForGroup`: group/neighbour lines carry the full path, **folders** count and files/sloc/hk/cycle, the `_root` collapse sentinel shown as `/`; hovering a caller/dependency neighbour box shows the same crate-style stats), `setupEdgeHighlight(svgFrame, level)` (must run **before** `setupTooltips`, which removes SVG `<title>`s; the **Fan-in/out overlay** is composed here (`composeFanSections` — see **Fan-in / Fan-out sections** below); `cluster_crate_*` overview clusters highlight all edges of the groups inside them; an expanded cluster's **background is inert** (the folder/crate is already open) — only its **name/path label** (`<text>`) navigates: **clicking a crate cluster's label drills into the whole crate** (`drillIntoGroup(label, level, 0)`), a drilled **directory sub-cluster**'s label drills into that folder — its representative node found by focus-relative dir match (`nodeRelDir` = `stripDirPrefix`∘`nodeFullDir`), so it stays clickable regardless of SVG nesting (the guard is `if (!e.target.closest('text')) return`); a **neighbour crate box** click drills into that crate's folder (`crateFocusTarget`)), `setupTooltips`. **Hover is debounced + de-flickered** (`HOVER_DELAY`): `wireNodeHover` delays the glow/raise so quick passes don't flash and clears every other `node-hl` first (never two glows at once), and **ignores a re-`mouseenter` while already active** — `raisePaint` reparents the node (`appendChild`) for paint order (SVG has no z-index), which re-fires `mouseenter` on the same node; the guard stops that re-firing the whole hover; a single shared `ehSchedule` timer drives **all** edge-highlight changes (nodes and clusters) so crossing boundaries never flashes the arrows. A **cluster** `mouseleave` is **ignored while the pointer is still inside the cluster's bounding box** — nodes/edges paint as siblings *on top* of the cluster background, so crossing them would otherwise fire a spurious leave→clear→re-apply and flicker the folder highlight. Edge opacity is **snapped, not transitioned** (`map-svg.css`): animating opacity on hundreds of edge `path`/`polygon` elements promoted each to its own compositor layer for the tween and flickered the whole page (worse with more elements) — confirmed via a performance trace. | | `panzoom.js` | `setupPanZoom()` — viewBox drag-to-pan, +/−/fit/fullscreen buttons, the map's size-mode + node-filter rows, and the drill-back button. The size/filter buttons are **built per render** by `renderMapControls` (view-state.js) from the level's `ui.size` / `ui.filter` — nothing hardcoded in the HTML — so clicks are handled by **delegation** on the `.size-controls` container (`data-size` toggles `window.nodeSizeMode`, `data-filter` toggles `window.nodeFilter`). (the reveal-depth control now lives in the breadcrumb's lens chip, not a standalone panzoom button). In **fullscreen** the page `<header>` (and body-attached overlays) move under the frame and the header stays **persistently visible** in a top `.fs-bar` (no slide-in); the floating top controls are offset below it. The default framing is the **capped fit** (`fitVB`): the content (already including the reserved, fully-**expanded** Fan-in/out bands — `composeFanSections` runs first, so the framing assumes the sections expanded) is fit and centred, never zoomed IN past `MAX_FIT_ZOOM` (1.3× absolute = frame px per SVG unit). It keeps the **top strip free** for the breadcrumb (`topReservePx` = the breadcrumb's bottom relative to the frame + ~12px, ~50 fallback), fitting the content into the area *below* it; `frame.dataset.naturalVB` is set to this framing so `renderView`'s preserve step only kicks in once the user actually pans/zooms away from it. The fit button (`zoomOut`) animates to the same framing. | ### Node modal / popup | File | Purpose | |------|---------| -| `modal.js` | `getModal()`/`closeModal()` overlay shell; delegated copy/select handlers; Esc/Space keys; mirrors the map's ⌘/Shift gestures inside the popup diagram; `setModalDiagram` re-attaches the shortcut legend. **Close-time drill**: if the user navigated between files inside the popup so the file shown at close sits in a **different folder** than the one the popup opened on (`_modalOpenId` anchor), `closeModal` lands the map in the close file's folder (`focusFolderTarget` → `drillIntoGroup`); opening and closing within the **same folder** (or on the same file) leaves the map exactly where it was. The **header breadcrumb** click handler: a path/`root` chip drills the map there and closes the modal; the **tier dropdown** switches the dimension *without* closing — it flips the tier (and the map behind), keeps the same file open, and re-renders the header (`nodeHeaderHtml`) in the new representation. | +| `modal.js` | `getModal()`/`closeModal()` overlay shell; delegated copy/select handlers; Esc/Space keys; mirrors the map's ⌥/Shift gestures inside the popup diagram; `setModalDiagram` re-attaches the shortcut legend. **Close-time drill**: if the user navigated between files inside the popup so the file shown at close sits in a **different folder** than the one the popup opened on (`_modalOpenId` anchor), `closeModal` lands the map in the close file's folder (`focusFolderTarget` → `drillIntoGroup`); opening and closing within the **same folder** (or on the same file) leaves the map exactly where it was. The **header breadcrumb** click handler: a path/`root` chip drills the map there and closes the modal; the **tier dropdown** switches the dimension *without* closing — it flips the tier (and the map behind), keeps the same file open, and re-renders the header (`nodeHeaderHtml`) in the new representation. | | `node-popup.js` | `buildDiagramSVG()` — the per-node neighbourhood SVG and `markPopupSelected()`. Neighbours (deduped by far node, every edge kind) are laid out as **vertically-stacked blocks, 5 cards per row, each block a fixed 5-wide** (the main node spans the same width). Top→bottom: `external` callers, one `<group> in: <value>` block per OTHER group, same-group `<group> in:` (fan in), the **main node**, same-group `<group> out:` (fan out), one `<group> out: <value>` block per other group, `external` dependencies. Block/tooltip labels use the **grouping key** (`ui.grouping.key`, e.g. `crate`/`module`) — never hardcoded. The fan-in/out **arrow connects the node to the nearest INTERNAL block** — the same-group `fan` block, or (when there is no same-group one) the closest cross-group `<group> in/out` block — so it shows even for purely cross-group coupling; only the external blocks (3rd-party, tracked as `fan_*_external`) carry no arrow. The arrow (line + head) is **coloured to match the block it points to** (blue same-group / green callers / orange deps, via a per-colour `<marker>`), and is **dashed and unlabelled** when `fan_in`/`fan_out` is 0 — the block then links only through non-flow `contains`/`reexports` edges, so its cards are dashed too; otherwise it carries the `Fan-in/out: N` count. Per-group blocks are sorted by card count so the biggest sits nearest the node. Each block has a thin solid outline (grey external / blue fan / green in / orange out) and a label whose group value is bold. Card tint: cross-group green (callers) / yellow (deps), same-group neutral blue, external grey, cycle red; a neighbour linked only through non-flow edges (`contains`/`reexports`) gets a **dashed** outline. Card hover tooltip = file name (title) + `<group>:` and `path:` rows (path with the `{token}` root marker stripped, leading slash kept → `/foo/bar`). | | `modal-content.js` | `buildModalContent()` — the modal's left field-table HTML (verbatim values via `fmtFull`, a git-host **Source** row, schema-driven metric rows/tooltips). `nodeCrumbsHtml`/`nodeHeaderHtml` build the header as a **file breadcrumb** in the same chip style as the map (tier dropdown + `all`/`root` + crate/folder path chips + the file, current), reused so the header can re-render in place on a tier switch. | | `source-links.js` | `gitWebBase`/`gitSourceUrl`/`nodeSourceUrl`/`connSourceLine` (git blob URLs at the analysed commit, optional `#L<line>`) and `absPath` (token→on-disk path). Pure, no DOM. | @@ -117,7 +117,7 @@ top-to-bottom). The viewer was split out of three former monoliths (`diagram.js` |------|---------| | `nav.js` | URL/history state: `getNavParams`, `navViewState`/`navViewUrl` (carry `level`/`side`/`group`/`mode`/`zoom`), `navPushView`/`navReplaceView`/`navPush`/`navSetSide`, and `openModalForNode` (the single node-modal entry point — on a **fresh** open it records `_modalOpenId`, the folder anchor `closeModal` compares the close-time file against). | | `view-state.js` | Which side is shown and how the map/tables reflect it: `activeSnap`/`viewMode`/`activeGraph`/`unionGraph`, `applySideVisibility`/`applySideSizing` (CSS-flip the shared union layout, no relayout), `setViewSide`, `recomputeAll`, `renderView`, and `applyViewState` (restores `group`/`mode`/`zoom` from a state object). | -| `snap-controls.js` | Header chrome: side-toggle wiring + `t` hotkey, the fly-out header, the warning count, `updateHeader`, the snapshot details/actions popup, and file-upload (snapshot swap) controls. **`updateFilesTab`** builds the per-level views + the **level switcher** from the snapshot's graph levels: the static `files` `.view` is the template, every other level (e.g. `functions`) is cloned from it (its `*-files` element ids rewritten to `*-<level>`), and the `.report-switch` tab row (one `<a data-view>` per level, wired to `switchToLevel` + URL persistence) is shown only when more than one level is present. Rendering is level-agnostic — `renderView`/`setupNodeTable` scope to the section + its `data-view` — so the clones need no special-casing; idempotent (a re-run on snapshot swap skips existing sections and just refreshes the switcher). The global map hotkeys (`t`, the Shift/Ctrl modifier classes in `map-interactions.js`) bail while the Prompt Generator popup is open (`window.isPromptPopupOpen` in `export-popup.js`) so keys — notably ⌘/Ctrl+C to copy — reach the popup instead of toggling map state. | +| `snap-controls.js` | Header chrome: side-toggle wiring + `t` hotkey, the fly-out header, the warning count, `updateHeader`, the snapshot details/actions popup, and file-upload (snapshot swap) controls. **`updateFilesTab`** builds the per-level views + the **level switcher** from the snapshot's graph levels: the static `files` `.view` is the template, every other level (e.g. `functions`) is cloned from it (its `*-files` element ids rewritten to `*-<level>`), and the `.report-switch` tab row (one `<a data-view>` per level, wired to `switchToLevel` + URL persistence) is shown only when more than one level is present. Rendering is level-agnostic — `renderView`/`setupNodeTable` scope to the section + its `data-view` — so the clones need no special-casing; idempotent (a re-run on snapshot swap skips existing sections and just refreshes the switcher). The global map hotkeys (`t`, the Shift/Alt modifier classes in `map-interactions.js`) bail while the Prompt Generator popup is open (`window.isPromptPopupOpen` in `export-popup.js`) so keys — notably ⌘/Ctrl+C to copy — reach the popup instead of toggling map state. | | `app.js` | The thin `DOMContentLoaded` bootstrap: read embedded snapshots, compute diff/cycles/meta, restore side/zoom/drill/node from the URL, load graphviz, render, and the `popstate` handler. On a **fresh load** (no nav state in the URL) it opens a default view: the **files** tier, drilled through any single-folder chain from the root (`autoFocusSegs` — descend while a folder holds exactly one subfolder and no direct files) to the first branching folder, at the node-budget reveal depth (`landingFocusDig`). | | `ui.js` | Intentionally empty (kept because assets are inlined by name). | diff --git a/docs/code-ranker-viewer/PRD.md b/docs/code-ranker-viewer/PRD.md index fbbf4835..348f3e94 100644 --- a/docs/code-ranker-viewer/PRD.md +++ b/docs/code-ranker-viewer/PRD.md @@ -272,14 +272,14 @@ Selection also works directly on the map: **holding Shift** turns the main diagram into a selection surface (the cursor changes over the SVG), and Shift-clicking an SVG node toggles its selection — exactly like ticking its table checkbox, kept in sync — instead of opening the modal. Holding the -**"open source" modifier** — **⌘ on macOS, Ctrl elsewhere** (Ctrl is left -alone on macOS, where it maps to right-click) — likewise changes the cursor -and turns a node click into "open source": it opens the file on the project's -git host (from `git.origin`) in a new tab instead of the modal (project files -only). While either modifier is held — or the cursor hovers the right edge — the map's -right-side controls (zoom and node-size) and a bottom-left shortcut legend are -revealed; the legend spells out the active keys for the platform (⌘ on macOS, -Ctrl elsewhere). +**"open source" modifier** — **Alt/Option (⌥) on every platform** (chosen over +⌘, which clashed with copy/paste on macOS, and over Ctrl, which is right-click on +macOS) — likewise changes the cursor and turns a node click into "open source": +it opens the file on the project's git host (from `git.origin`) in a new tab +instead of the modal (project files only). While either modifier is held — or the +cursor hovers the right edge — the map's right-side controls (zoom and node-size) +and a bottom-left shortcut legend are revealed; the legend spells out the active +keys (⌥ Option on macOS, Alt elsewhere — the same gesture either way). The modal popup opened by clicking a row or an SVG node is fullscreen (locks body scroll). Its **header is a breadcrumb** of the file's location in the same style as the map (tier dropdown → `all`/`root` → crate/folder chips → the From 66238a8e0a6593e8ca5393c0000dbf3ae2f0f2e3 Mon Sep 17 00:00:00 2001 From: Roman Fedorov <Roman.Fedorov@constructor.tech> Date: Tue, 23 Jun 2026 10:00:23 +0300 Subject: [PATCH 02/40] refactor: unify terminology (principle / metric / rule / focus) + offline --doc catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the vocabulary consistent across CLI, config, snapshot, viewer and docs, and make every principle/metric doc reachable offline. Breaking by design (few clients yet); SARIF's standard `rules` field is the only "rules" kept. Terminology - preset → **principle** everywhere: Rust types (`Preset`→`Principle`, `preset.rs`→`principle.rs`), snapshot JSON field `presets`→`principles`, config `[[presets]]`/`[presets.<ID>]` → `[[principles]]`/`[principles.<ID>]`, viewer JS (`snap.principles`) + CSS classes (`exp-principle-*`), docs. - `--focus-rule` → **`--focus`** (old name removed, no alias). value_name fixed (`check`: RULE|GROUP, `report`: METRIC | PRINCIPLE); dropped the false "Mirrors check's --focus-rule" docstring (the two are different operations). - **rule** is reserved for the `check` gate only (`[rules]`, rule ids, SARIF `rules`); principles/metrics are no longer called "rules". - **lens**/**axis** (the framing concept) → **focus**. Kept the unrelated viewer "reveal-depth lens chip" and OCP's "axis of change". Offline docs - `remediation` now points at `code-ranker report --doc <ID>` instead of a GitHub download URL; `--doc` resolves any base doc by filename stem (`Fan-in`, `metrics`, `AI`) and `--doc cycle` → the ADP doc. - New **`--doc AI`**: an AI-agent overview + a catalog auto-assembled from every base doc's TL;DR (`languages/base/AI.md` + `templates.rs::tldr_index`). - The `cycle` focus prompt now mirrors the ADP prompt (cycle is ADP's metric lens) — `synth_metric_principle` borrows the ADP principle's framing. Goldens regenerated (snapshot key + remediation strings). New unit tests cover the cycle→ADP borrow, `--doc cycle`→ADP, the AI index expansion and filename fallback. `make all` + `make e2e` green. --- crates/code-ranker-cli/src/check.rs | 22 +-- crates/code-ranker-cli/src/cli.rs | 24 +-- .../code-ranker-cli/src/config/defaults.toml | 2 +- crates/code-ranker-cli/src/config/model.rs | 24 +-- .../code-ranker-cli/src/config/model_test.rs | 10 +- crates/code-ranker-cli/src/export.rs | 6 +- crates/code-ranker-cli/src/main.rs | 8 +- crates/code-ranker-cli/src/pipeline.rs | 23 +-- crates/code-ranker-cli/src/pipeline_test.rs | 12 +- crates/code-ranker-cli/src/plugin/mod.rs | 10 +- crates/code-ranker-cli/src/recommend.rs | 83 ++++++---- .../code-ranker-cli/src/recommend/prompt.rs | 44 +++--- .../src/recommend/scorecard.rs | 28 ++-- crates/code-ranker-cli/src/recommend_test.rs | 143 ++++++++++------- crates/code-ranker-cli/src/report.rs | 63 ++++---- crates/code-ranker-cli/src/templates.rs | 145 ++++++++++++++++-- crates/code-ranker-cli/src/templates_test.rs | 129 ++++++++++++++-- crates/code-ranker-cli/tests/e2e.rs | 37 ++--- crates/code-ranker-graph/metrics/builtin.toml | 14 +- crates/code-ranker-graph/metrics/prompt.md | 2 +- crates/code-ranker-graph/src/snapshot.rs | 10 +- crates/code-ranker-plugin-api/src/lib.rs | 4 +- crates/code-ranker-plugin-api/src/plugin.rs | 14 +- .../src/{preset.rs => principle.rs} | 22 +-- .../code-ranker-plugin-api/src/toml_merge.rs | 16 +- crates/code-ranker-plugins/Cargo.toml | 2 +- crates/code-ranker-plugins/src/config/mod.rs | 7 +- .../code-ranker-plugins/src/config/specs.rs | 30 ++-- crates/code-ranker-plugins/src/defaults.toml | 42 ++--- .../src/languages/README.md | 8 +- .../src/languages/c/mod.rs | 6 +- .../c/tests/sample/code-ranker-report.json | 12 +- .../src/languages/cpp/mod.rs | 6 +- .../cpp/tests/sample/code-ranker-report.json | 12 +- .../src/languages/csharp/mod.rs | 6 +- .../tests/sample/code-ranker-report.json | 12 +- .../src/languages/ecmascript/config.toml | 2 +- .../src/languages/go/config.toml | 2 +- .../src/languages/go/mod.rs | 6 +- .../go/tests/sample/code-ranker-report.json | 12 +- .../src/languages/javascript/config.toml | 2 +- .../src/languages/javascript/mod.rs | 6 +- .../tests/sample/code-ranker-report.json | 16 +- .../src/languages/markdown/mod.rs | 4 +- .../src/languages/markdown/tests/mod_rs.rs | 4 +- .../tests/sample/code-ranker-report.json | 6 +- .../src/languages/python/config.toml | 2 +- .../src/languages/python/mod.rs | 10 +- .../tests/sample/code-ranker-report.json | 16 +- .../src/languages/rust/cfg.rs | 4 +- .../src/languages/rust/config.toml | 2 +- .../src/languages/rust/mod.rs | 8 +- .../rust/tests/sample/code-ranker-report.json | 16 +- .../src/languages/typescript/config.toml | 2 +- .../src/languages/typescript/mod.rs | 8 +- .../tests/sample/code-ranker-report.json | 16 +- .../code-ranker-plugins/src/tests/config.rs | 64 ++++---- .../src/assets/export-popup.js | 112 +++++++------- .../code-ranker-viewer/src/assets/export.css | 26 ++-- .../code-ranker-viewer/src/assets/schema.js | 8 +- docs/DESIGN.md | 18 +-- docs/PRD.md | 10 +- docs/ai-skill.md | 30 ++-- docs/code-ranker-cli/CLI.md | 77 ++++++---- docs/code-ranker-cli/DESIGN.md | 20 +-- docs/code-ranker-cli/PRD.md | 6 +- docs/code-ranker-cli/USE-CASES.md | 20 +-- docs/code-ranker-cli/config.md | 16 +- docs/code-ranker-viewer/DESIGN.md | 6 +- docs/code-ranker-viewer/PRD.md | 4 +- docs/customization/README.md | 28 ++-- docs/customization/cel-reference.md | 2 +- docs/customization/config-resolution.md | 20 +-- docs/customization/custom-field-example.toml | 10 +- docs/templates.md | 10 +- languages/base/AI.md | 43 ++++++ languages/base/Cognitive.md | 2 +- languages/base/Cyclomatic.md | 2 +- languages/base/Fan-in.md | 4 +- languages/base/Fan-out.md | 4 +- languages/base/HK.md | 10 +- languages/typescript/OCP.md | 2 +- 82 files changed, 1030 insertions(+), 706 deletions(-) rename crates/code-ranker-plugin-api/src/{preset.rs => principle.rs} (72%) create mode 100644 languages/base/AI.md diff --git a/crates/code-ranker-cli/src/check.rs b/crates/code-ranker-cli/src/check.rs index f52a0557..88c637be 100644 --- a/crates/code-ranker-cli/src/check.rs +++ b/crates/code-ranker-cli/src/check.rs @@ -32,7 +32,7 @@ pub(crate) fn run_check( cycle_rules: &[String], thresholds: &[String], focus_path: &[String], - focus_rule: &[String], + focus: &[String], baseline: Option<&Path>, output_format: OutputFormat, top: Option<usize>, @@ -76,7 +76,7 @@ pub(crate) fn run_check( }; // Scope the gate. `--focus-path` keeps violations under the given files/folders; - // `--focus-rule` keeps violations of the given rule ids or concern groups. The + // `--focus` keeps violations of the given rule ids or concern groups. The // whole project is still analyzed, but a violation outside an active focus is // dropped — neither reported nor counted toward the exit code. With both set, a // violation must satisfy both (path AND rule). A locationless violation can't be @@ -86,10 +86,10 @@ pub(crate) fn run_check( violation_rel_path(&v.location).is_some_and(|rel| path_matches(rel, focus_path)) }); } - if !focus_rule.is_empty() { - findings.retain(|v| rule_matches(v, focus_rule)); + if !focus.is_empty() { + findings.retain(|v| rule_matches(v, focus)); } - let scope_note = focus_scope_note(focus_path, focus_rule); + let scope_note = focus_scope_note(focus_path, focus); let total = findings.len(); // Rank worst-first by breach magnitude; `--top` limits only what is @@ -293,10 +293,10 @@ fn path_matches(rel: &str, focus: &[String]) -> bool { }) } -/// Whether a violation matches one of the `--focus-rule` entries. An entry matches +/// Whether a violation matches one of the `--focus` entries. An entry matches /// the full rule id (`threshold.file.hk`, `check.inline_tests_too_large`), the bare /// id after the last dot (`inline_tests_too_large`), or the concern group (`TST`, -/// `CPL`) — so `--focus-rule TST` and `--focus-rule inline_tests_too_large` both work. +/// `CPL`) — so `--focus TST` and `--focus inline_tests_too_large` both work. fn rule_matches(v: &config::Violation, focus: &[String]) -> bool { focus .iter() @@ -304,14 +304,14 @@ fn rule_matches(v: &config::Violation, focus: &[String]) -> bool { } /// The trailing "(focused on …)" note for the human header, covering whichever of -/// `--focus-path` / `--focus-rule` are active (empty when neither is). -fn focus_scope_note(focus_path: &[String], focus_rule: &[String]) -> String { +/// `--focus-path` / `--focus` are active (empty when neither is). +fn focus_scope_note(focus_path: &[String], focus: &[String]) -> String { let mut parts = Vec::new(); if !focus_path.is_empty() { parts.push(format!("path {}", focus_path.join(", "))); } - if !focus_rule.is_empty() { - parts.push(format!("rule {}", focus_rule.join(", "))); + if !focus.is_empty() { + parts.push(format!("rule {}", focus.join(", "))); } if parts.is_empty() { String::new() diff --git a/crates/code-ranker-cli/src/cli.rs b/crates/code-ranker-cli/src/cli.rs index 6a65eb0c..d5ac527b 100644 --- a/crates/code-ranker-cli/src/cli.rs +++ b/crates/code-ranker-cli/src/cli.rs @@ -107,8 +107,8 @@ pub(crate) enum Command { /// full rule id (`threshold.file.hk`, `check.inline_tests_too_large`), the /// bare id (`inline_tests_too_large`), or a group (`TST`, `CPL`). Combine with /// `--focus-path` to intersect (a violation must match both). - #[arg(long = "focus-rule", value_name = "RULE|GROUP")] - focus_rule: Vec<String>, + #[arg(long = "focus", value_name = "RULE|GROUP")] + focus: Vec<String>, /// Baseline snapshot (`.json`/`.html`). Switches the gate to relative mode: /// fail only on regressions (new violations) against the baseline, not on @@ -183,8 +183,8 @@ pub(crate) enum Command { output_codequality_path: Option<String>, /// Emit the AI fix-prompt, auto-targeted at the single worst module of the - /// worst-violating principle (requires `--top 1`; default to a `…-{preset}.md` - /// file, where {preset} is that principle). + /// worst-violating principle (requires `--top 1`; default to a `…-{principle}.md` + /// file, where {principle} is that principle). #[arg(long = "output.prompt")] output_prompt: bool, @@ -193,7 +193,7 @@ pub(crate) enum Command { output_scorecard: bool, /// AI-prompt destination: a path or name template (extra placeholder - /// {preset}), or `stdout`/`-`. Selects the prompt format. + /// {principle}), or `stdout`/`-`. Selects the prompt format. #[arg(long = "output.prompt.path", value_name = "PATH")] output_prompt_path: Option<String>, @@ -202,15 +202,15 @@ pub(crate) enum Command { #[arg(long = "output.scorecard.path", value_name = "PATH")] output_scorecard_path: Option<String>, - /// Focus the scorecard / prompt on one axis. Accepts a **metric** - /// (`hk`, `sloc`, … — case-insensitive, matched by value so it works with - /// or without a configured threshold), the full threshold rule id - /// (`threshold.file.hk`), or a **principle** id (`LSP`, `ADP`, …). A metric + /// Focus the scorecard / prompt on one **metric** (`hk`, `sloc`, … — + /// case-insensitive, matched by value so it works with or without a + /// configured threshold) or **principle** id (`LSP`, `ADP`, …). A metric /// frames the output by the metric itself (no SOLID wrapper); a principle by /// that design principle. Without it, the scorecard spans every principle and - /// the prompt auto-targets the worst. Mirrors `check`'s `--focus-rule`. - #[arg(long = "focus-rule", value_name = "METRIC | RULE | PRINCIPLE")] - focus_rule: Option<String>, + /// the prompt auto-targets the worst. (On `check`, `--focus` instead filters + /// the gate by rule/group — a different operation.) + #[arg(long = "focus", value_name = "METRIC | PRINCIPLE")] + focus: Option<String>, /// Restrict the scorecard / prompt to modules under these paths (repeatable). /// The whole project is still analyzed (the graph needs it), but only modules diff --git a/crates/code-ranker-cli/src/config/defaults.toml b/crates/code-ranker-cli/src/config/defaults.toml index 36a4d7ad..1ab8e3ec 100644 --- a/crates/code-ranker-cli/src/config/defaults.toml +++ b/crates/code-ranker-cli/src/config/defaults.toml @@ -58,7 +58,7 @@ enabled = false # overview defaulting to the stdout stream. Both stay OFF unless requested via # `--output.prompt` / `--output.scorecard`; these just supply the default path. [output.prompt] -path = ".code-ranker/{ts}-{git-hash-3}-{preset}.md" +path = ".code-ranker/{ts}-{git-hash-3}-{principle}.md" [output.scorecard] path = "stdout" diff --git a/crates/code-ranker-cli/src/config/model.rs b/crates/code-ranker-cli/src/config/model.rs index a2a6472f..5f597f4a 100644 --- a/crates/code-ranker-cli/src/config/model.rs +++ b/crates/code-ranker-cli/src/config/model.rs @@ -57,12 +57,12 @@ pub struct Config { /// / card / JSON stats. Raw table; parsed by `list_override::report_override_section`. #[serde(default)] pub report: toml::Table, - /// Project-defined Prompt-Generator presets (`[presets.<ID>]`), keyed by the - /// preset id. Appended to the active plugin's catalog (a same-id project preset + /// Project-defined Prompt-Generator principles (`[principles.<ID>]`), keyed by the + /// principle id. Appended to the active plugin's catalog (a same-id project principle /// overrides the plugin's), so a project can recommend/scorecard on its own /// custom metric. Empty by default — absent → no change to output. #[serde(default)] - pub presets: BTreeMap<String, PresetDef>, + pub principles: BTreeMap<String, PrincipleDef>, /// Per-file doc-corpus overrides (`[templates.languages.<lang>.<ID>]`): use a /// file from disk in place of the embedded `languages/<lang>/<ID>.md`. Empty by /// default — absent → the embedded corpus is used unchanged. @@ -97,12 +97,12 @@ impl Default for Config { } } -/// A project-config preset (`[presets.<ID>]`) — the table key is the id. Mirrors -/// the plugin [`Preset`](code_ranker_plugin_api::Preset) but with sane +/// A project-config principle (`[principles.<ID>]`) — the table key is the id. Mirrors +/// the plugin [`Principle`](code_ranker_plugin_api::Principle) but with sane /// defaults so a project entry needs only `sort_metric` (+ usually `title`). #[derive(Debug, Clone, Deserialize, Default)] #[serde(default, deny_unknown_fields)] -pub struct PresetDef { +pub struct PrincipleDef { /// Button label; defaults to the id. pub label: Option<String>, /// Principle title (first heading of the generated prompt); defaults to the id. @@ -112,17 +112,17 @@ pub struct PresetDef { /// Link to a principle doc, if any. pub doc_url: Option<String>, /// The metric the recommended-node list sorts by (an attribute key, or the - /// pseudo-metric `"cycle"`). Required in practice — the lens the preset is. + /// pseudo-metric `"cycle"`). Required in practice — the lens the principle is. pub sort_metric: String, - /// Connection sets the preset pre-selects: any of `"in"` / `"out"` / `"common"`. + /// Connection sets the principle pre-selects: any of `"in"` / `"out"` / `"common"`. pub connections: Vec<String>, } -impl PresetDef { - /// Build the runtime [`Preset`](code_ranker_plugin_api::Preset) for +impl PrincipleDef { + /// Build the runtime [`Principle`](code_ranker_plugin_api::Principle) for /// this entry, defaulting `label` / `title` to the id. - pub fn to_preset(&self, id: &str) -> code_ranker_plugin_api::Preset { - code_ranker_plugin_api::Preset { + pub fn to_principle(&self, id: &str) -> code_ranker_plugin_api::Principle { + code_ranker_plugin_api::Principle { id: id.to_string(), label: self.label.clone().unwrap_or_else(|| id.to_string()), title: self.title.clone().unwrap_or_else(|| id.to_string()), diff --git a/crates/code-ranker-cli/src/config/model_test.rs b/crates/code-ranker-cli/src/config/model_test.rs index ba399cac..c4b05a4d 100644 --- a/crates/code-ranker-cli/src/config/model_test.rs +++ b/crates/code-ranker-cli/src/config/model_test.rs @@ -117,15 +117,15 @@ fn threshold_value_accepts_int_and_float() { } #[test] -fn project_preset_parses_with_id_defaults() { - // `[presets.TSR]` keys the preset by its table name; `label`/`title` +fn project_principle_parses_with_id_defaults() { + // `[principles.TSR]` keys the principle by its table name; `label`/`title` // default to the id, so a minimal entry needs only `sort_metric`. let cfg = toml::from_str::<Config>( - "[presets.TSR]\nsort_metric = \"tsr\"\nprompt = \"fix the ratio\"\n", + "[principles.TSR]\nsort_metric = \"tsr\"\nprompt = \"fix the ratio\"\n", ) .unwrap(); - let def = &cfg.presets["TSR"]; - let p = def.to_preset("TSR"); + let def = &cfg.principles["TSR"]; + let p = def.to_principle("TSR"); assert_eq!(p.id, "TSR"); assert_eq!(p.label, "TSR"); assert_eq!(p.title, "TSR"); diff --git a/crates/code-ranker-cli/src/export.rs b/crates/code-ranker-cli/src/export.rs index 8aa77054..192a7a17 100644 --- a/crates/code-ranker-cli/src/export.rs +++ b/crates/code-ranker-cli/src/export.rs @@ -7,7 +7,7 @@ //! //! Diagnostic: it shows EVERY effective parameter so a user can see what they may //! override. The two sections use different schemas (and the project / plugin -//! `presets` shapes collide), so the file is a human-facing dump, not directly +//! `principles` shapes collide), so the file is a human-facing dump, not directly //! reusable as a single `--config`. use crate::cli::AnalyzeArgs; @@ -121,9 +121,9 @@ mod tests { assert_eq!(project["ignore"]["tests"].as_bool(), Some(false)); assert_eq!(project["ignore"]["gitignore"].as_bool(), Some(true)); assert!(project["output"]["json"]["path"].as_str().is_some()); - // [plugin]: the resolved python language config (its presets catalog). + // [plugin]: the resolved python language config (its principles catalog). assert_eq!(plugin["doc_lang"].as_str(), Some("python")); - assert!(!plugin["presets"].as_array().unwrap().is_empty()); + assert!(!plugin["principles"].as_array().unwrap().is_empty()); } #[test] diff --git a/crates/code-ranker-cli/src/main.rs b/crates/code-ranker-cli/src/main.rs index 08d36013..9b908a30 100644 --- a/crates/code-ranker-cli/src/main.rs +++ b/crates/code-ranker-cli/src/main.rs @@ -43,7 +43,7 @@ fn main() -> Result<()> { cycle_rules, thresholds, focus_path, - focus_rule, + focus, baseline, output_format, top, @@ -54,7 +54,7 @@ fn main() -> Result<()> { &cycle_rules, &thresholds, &focus_path, - &focus_rule, + &focus, baseline.as_deref(), output_format, top, @@ -76,7 +76,7 @@ fn main() -> Result<()> { output_scorecard, output_prompt_path, output_scorecard_path, - focus_rule, + focus, focus_path, severity, top, @@ -105,7 +105,7 @@ fn main() -> Result<()> { scorecard_path: output_scorecard_path, }, report::ReportReco { - focus_rule, + focus, focus_path, severity, top, diff --git a/crates/code-ranker-cli/src/pipeline.rs b/crates/code-ranker-cli/src/pipeline.rs index 04ca8fcd..964519b6 100644 --- a/crates/code-ranker-cli/src/pipeline.rs +++ b/crates/code-ranker-cli/src/pipeline.rs @@ -323,10 +323,11 @@ pub(crate) fn analyze_directory( versions.insert(k, v); } - // Plugin catalog presets, then the project's own (`[presets.<ID>]`): a - // same-id project preset overrides the plugin's, a new id appends. So a + // Plugin catalog principles, then the project's own (`[principles.<ID>]`): a + // same-id project principle overrides the plugin's, a new id appends. So a // project can recommend / scorecard on its custom metric. - let presets = merge_project_presets(plugin::presets(&plugin_name, &input), &cfg.presets); + let principles = + merge_project_principles(plugin::principles(&plugin_name, &input), &cfg.principles); // Prompt-Generator scaffolding: the built-in `metrics/prompt.md`, or a // `[templates] prompt = "<path>"` override read from disk (same `## <field>` @@ -350,7 +351,7 @@ pub(crate) fn analyze_directory( git, timings, graphs, - presets, + principles, prompt, ); @@ -364,15 +365,15 @@ pub(crate) fn analyze_directory( }) } -/// Merge the project's `[presets.<ID>]` over the plugin catalog: a same-id project -/// preset replaces the plugin's (in place, keeping catalog order), a new id is +/// Merge the project's `[principles.<ID>]` over the plugin catalog: a same-id project +/// principle replaces the plugin's (in place, keeping catalog order), a new id is /// appended. So a project can recommend / scorecard on its own custom metric. -fn merge_project_presets( - mut catalog: Vec<code_ranker_plugin_api::Preset>, - project: &BTreeMap<String, config::model::PresetDef>, -) -> Vec<code_ranker_plugin_api::Preset> { +fn merge_project_principles( + mut catalog: Vec<code_ranker_plugin_api::Principle>, + project: &BTreeMap<String, config::model::PrincipleDef>, +) -> Vec<code_ranker_plugin_api::Principle> { for (id, def) in project { - let p = def.to_preset(id); + let p = def.to_principle(id); match catalog.iter_mut().find(|e| e.id == p.id) { Some(existing) => *existing = p, None => catalog.push(p), diff --git a/crates/code-ranker-cli/src/pipeline_test.rs b/crates/code-ranker-cli/src/pipeline_test.rs index ac92d234..21f769e5 100644 --- a/crates/code-ranker-cli/src/pipeline_test.rs +++ b/crates/code-ranker-cli/src/pipeline_test.rs @@ -2,9 +2,9 @@ use super::*; use std::fs; #[test] -fn project_presets_override_then_append() { - use code_ranker_plugin_api::Preset; - let catalog = vec![Preset { +fn project_principles_override_then_append() { + use code_ranker_plugin_api::Principle; + let catalog = vec![Principle { id: "CPX".into(), label: "CPX".into(), title: "Complexity".into(), @@ -17,7 +17,7 @@ fn project_presets_override_then_append() { // Same id → replaces the catalog entry in place. project.insert( "CPX".to_string(), - config::model::PresetDef { + config::model::PrincipleDef { prompt: "new".into(), sort_metric: "cyclomatic".into(), ..Default::default() @@ -26,12 +26,12 @@ fn project_presets_override_then_append() { // New id → appended. project.insert( "TSR".to_string(), - config::model::PresetDef { + config::model::PrincipleDef { sort_metric: "tsr".into(), ..Default::default() }, ); - let merged = merge_project_presets(catalog, &project); + let merged = merge_project_principles(catalog, &project); assert_eq!(merged.len(), 2); let cpx = merged.iter().find(|p| p.id == "CPX").unwrap(); assert_eq!(cpx.sort_metric, "cyclomatic", "same id replaced in place"); diff --git a/crates/code-ranker-cli/src/plugin/mod.rs b/crates/code-ranker-cli/src/plugin/mod.rs index 170adb1c..552b0ecc 100644 --- a/crates/code-ranker-cli/src/plugin/mod.rs +++ b/crates/code-ranker-cli/src/plugin/mod.rs @@ -12,7 +12,7 @@ use code_ranker_plugin_api::{ metrics::MetricInputs, node::Node, plugin::{LanguagePlugin, PluginInput}, - preset::Preset, + principle::Principle, }; use std::collections::BTreeMap; use std::path::Path; @@ -116,11 +116,11 @@ pub fn report_overrides(name: &str) -> code_ranker_plugin_api::report::ReportOve .unwrap_or_default() } -/// The matching plugin's Prompt-Generator presets (the common catalog plus any -/// language-specific presets), built from its own config. -pub fn presets(name: &str, input: &PluginInput) -> Vec<Preset> { +/// The matching plugin's Prompt-Generator principles (the common catalog plus any +/// language-specific principles), built from its own config. +pub fn principles(name: &str, input: &PluginInput) -> Vec<Principle> { match registry().iter().find(|p| p.name() == name) { - Some(p) => p.presets(input), + Some(p) => p.principles(input), None => Vec::new(), } } diff --git a/crates/code-ranker-cli/src/recommend.rs b/crates/code-ranker-cli/src/recommend.rs index 2d7768ba..d9e4b61a 100644 --- a/crates/code-ranker-cli/src/recommend.rs +++ b/crates/code-ranker-cli/src/recommend.rs @@ -3,7 +3,7 @@ //! It is the console counterpart of the HTML viewer's Prompt Generator: the same //! ranking (`reco_for` ≈ `recoFor` in `export-popup.js`) and the same Markdown //! prompt (`compose_prompt` ≈ `composePrompt` + `buildContent`), plus a console -//! triage table (`render_scorecard`) that mirrors the viewer's per-preset badges. +//! triage table (`render_scorecard`) that mirrors the viewer's per-principle badges. //! //! All of it is **advisory**, derived from the snapshot's gate-driven //! `node_attributes[*].thresholds` (the `info` / `warning` tiers) — never a gate. @@ -14,7 +14,7 @@ use anyhow::{Result, bail}; use code_ranker_graph::level_graph::{CycleGroup, LevelGraph}; -pub use code_ranker_plugin_api::Preset; +pub use code_ranker_plugin_api::Principle; use code_ranker_plugin_api::{ attrs::{AttrValue, ValueType}, level::Thresholds, @@ -28,7 +28,7 @@ mod scorecard; pub use prompt::compose_prompt; pub use scorecard::render_scorecard; -/// What `--focus <NAME>` resolves to: a SOLID-style design **principle** (a preset +/// What `--focus <NAME>` resolves to: a SOLID-style design **principle** (a principle /// id, e.g. `LSP`) or a **metric** (an attribute key, e.g. `hk`). The two live in /// separate namespaces — principle ids are upper-case codes, metric keys are /// lower-case — so a name maps unambiguously to one lens. @@ -38,14 +38,14 @@ pub enum Focus { Metric(String), } -/// Resolve a `--focus-rule` name to a [`Focus`]. Accepts, in order: a preset id +/// Resolve a `--focus` name to a [`Focus`]. Accepts, in order: a principle id /// (exact, e.g. `LSP`) → a principle; a metric — by its bare key or a full /// threshold rule id, case-insensitive (`HK`, `hk`, `threshold.file.hk` all → /// `hk`), matched against any attribute the level carries (by value, so it works /// whether or not the metric has a configured threshold) or the `cycle` /// pseudo-metric. Unknown names are fatal, listing both namespaces. -pub fn resolve_focus(level: &LevelGraph, presets: &[Preset], name: &str) -> Result<Focus> { - if presets.iter().any(|p| p.id == name) { +pub fn resolve_focus(level: &LevelGraph, principles: &[Principle], name: &str) -> Result<Focus> { + if principles.iter().any(|p| p.id == name) { return Ok(Focus::Principle(name.to_string())); } // A metric can be named bare (`hk`) or as a full rule id (`threshold.file.hk`); @@ -65,21 +65,44 @@ pub fn resolve_focus(level: &LevelGraph, presets: &[Preset], name: &str) -> Resu .collect(); metrics.push("cycle"); metrics.sort_unstable(); - let principles: Vec<&str> = presets.iter().map(|p| p.id.as_str()).collect(); + let principles: Vec<&str> = principles.iter().map(|p| p.id.as_str()).collect(); bail!( - "unknown --focus-rule '{name}'. Metrics: {}. Principles: {}", + "unknown --focus '{name}'. Metrics: {}. Principles: {}", metrics.join(", "), principles.join(", ") ); } -/// Build a throwaway [`Preset`] that frames a **metric** as its own principle, so +/// Build a throwaway [`Principle`] that frames a **metric** as its own principle, so /// the metric-lens prompt reuses [`compose_prompt`] verbatim — the title is the /// metric (`HK — Henry–Kafura`), the summary its `description`, the `doc_url` the /// fix-prompt doc linked from its `remediation`, and the ranking axis the metric /// itself. No SOLID principle is involved. Coupling metrics also pull the in/out /// connection lists (the HK fix workflow needs the crossroads); others omit them. -pub fn synth_metric_preset(level: &LevelGraph, metric: &str) -> Preset { +pub fn synth_metric_principle( + level: &LevelGraph, + principles: &[Principle], + metric: &str, +) -> Principle { + // The `cycle` pseudo-metric IS the ADP principle's ranking axis (ADP's + // `sort_metric` is literally `cycle`). Frame its prompt like ADP — borrow that + // principle's title, prompt body, connection set, and doc — so `--focus cycle` + // reads almost exactly like `--focus ADP`; the scorecard still keeps the + // metric lens (its header comes from `metric_focus_label`, not this principle, and + // it drops the principle table). Falls through to generic framing if absent. + if metric == "cycle" + && let Some(adp) = principles.iter().find(|p| p.sort_metric == "cycle") + { + return Principle { + id: metric.to_string(), + label: metric.to_string(), + title: adp.title.clone(), + prompt: adp.prompt.clone(), + doc_url: adp.doc_url.clone(), + sort_metric: metric.to_string(), + connections: adp.connections.clone(), + }; + } let spec = level.node_attributes.get(metric); let label = spec .and_then(|s| s.short.as_deref().or(s.label.as_deref())) @@ -92,13 +115,13 @@ pub fn synth_metric_preset(level: &LevelGraph, metric: &str) -> Preset { }; let doc_url = spec .and_then(|s| s.remediation.as_deref()) - .and_then(first_url); + .and_then(doc_ref); let connections = if spec.and_then(|s| s.group.as_deref()) == Some("coupling") { vec!["in".to_string(), "out".to_string(), "common".to_string()] } else { Vec::new() }; - Preset { + Principle { id: metric.to_string(), label: label.to_string(), title, @@ -109,15 +132,17 @@ pub fn synth_metric_preset(level: &LevelGraph, metric: &str) -> Preset { } } -/// The first `http(s)` URL embedded in `s` (a metric's `remediation` text links -/// its fix-prompt doc), up to the first whitespace; `None` if there is none. -fn first_url(s: &str) -> Option<String> { - let start = s.find("http")?; - let url: String = s[start..] +/// The doc id a `remediation` string points at: the `<ID>` token after `--doc ` +/// in "Run `code-ranker report --doc <ID>` and follow its instructions." (the +/// `<ID>` is the canonical doc filename stem, e.g. `HK`, `Fan-in`, `ADP`). `None` +/// when the remediation names no doc. Shared with `templates::doc_rel_path`. +pub(crate) fn doc_ref(s: &str) -> Option<String> { + let after = s.split("--doc ").nth(1)?; + let id: String = after .chars() - .take_while(|c| !c.is_whitespace()) + .take_while(|c| !c.is_whitespace() && *c != '`') .collect(); - Some(url) + (!id.is_empty()).then_some(id) } /// Which threshold tier drives an output. `Auto` resolves to `Warning` when any @@ -254,7 +279,7 @@ pub fn reco_for<'a>(level: &'a LevelGraph, metric: &str) -> Reco<'a> { .then(bi.total_cmp(&ai)) }); // No configured threshold → no breaches (the metric still ranks for display, - // but never claims violations and so never wins `worst_preset`). + // but never claims violations and so never wins `worst_principle`). let (warning_count, info_count) = match th { Some(th) => ( sorted @@ -275,7 +300,7 @@ pub fn reco_for<'a>(level: &'a LevelGraph, metric: &str) -> Reco<'a> { } } -/// Cycle groups ranked worst-first for the ADP (cycle) preset: `chain` cycles +/// Cycle groups ranked worst-first for the ADP (cycle) principle: `chain` cycles /// before `mutual`, larger SCCs before smaller, so `--top 1` surfaces the single /// biggest chain. Ties broken by the first node id for determinism. fn ranked_cycle_groups(level: &LevelGraph) -> Vec<&CycleGroup> { @@ -292,7 +317,7 @@ fn ranked_cycle_groups(level: &LevelGraph) -> Vec<&CycleGroup> { /// The top-N cycle groups (see [`ranked_cycle_groups`]), each paired with its /// member nodes ordered by HK (worst first). A node id with no matching node is -/// skipped. This is the unit the ADP preset recommends on: `--top` counts +/// skipped. This is the unit the ADP principle recommends on: `--top` counts /// **cycles**, and every member of each selected cycle is listed. pub(super) fn top_cycle_groups( level: &LevelGraph, @@ -334,13 +359,13 @@ pub(super) fn tier_count(reco: &Reco, sev: Severity) -> usize { } /// The principle with the most violations: highest `warning` count, tie-broken by -/// `info` count, then by catalog order (the first preset wins on a tie). `None` -/// only if there are no presets. -pub fn worst_preset(level: &LevelGraph, presets: &[Preset]) -> Option<String> { - let mut best: Option<(&Preset, usize, usize)> = None; - for p in presets { +/// `info` count, then by catalog order (the first principle wins on a tie). `None` +/// only if there are no principles. +pub fn worst_principle(level: &LevelGraph, principles: &[Principle]) -> Option<String> { + let mut best: Option<(&Principle, usize, usize)> = None; + for p in principles { let r = reco_for(level, &p.sort_metric); - // Strictly-greater so the FIRST preset wins on a tie (catalog order). + // Strictly-greater so the FIRST principle wins on a tie (catalog order). let better = match best { None => true, Some((_, bw, bi)) => (r.warning_count, r.info_count) > (bw, bi), @@ -350,7 +375,7 @@ pub fn worst_preset(level: &LevelGraph, presets: &[Preset]) -> Option<String> { } } best.map(|(p, _, _)| p.id.clone()) - .or_else(|| presets.first().map(|p| p.id.clone())) + .or_else(|| principles.first().map(|p| p.id.clone())) } /// Count of project source files in the level. diff --git a/crates/code-ranker-cli/src/recommend/prompt.rs b/crates/code-ranker-cli/src/recommend/prompt.rs index d35b0c99..d5b4cc3c 100644 --- a/crates/code-ranker-cli/src/recommend/prompt.rs +++ b/crates/code-ranker-cli/src/recommend/prompt.rs @@ -8,36 +8,36 @@ use super::{ }; use anyhow::{Result, bail}; use code_ranker_graph::level_graph::LevelGraph; -use code_ranker_plugin_api::{Preset, PromptTemplate, node::Node}; +use code_ranker_plugin_api::{Principle, PromptTemplate, node::Node}; /// 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 preset's connection lists. +/// then the ranked offending modules, then the principle's connection lists. /// `focus_paths` (empty = no restriction) narrows the ranked modules to a subtree. pub fn compose_prompt( level: &LevelGraph, - presets: &[Preset], + principles: &[Principle], tmpl: &PromptTemplate, - preset_id: &str, + principle_id: &str, sev: Severity, top: Option<usize>, focus_paths: &[String], ) -> Result<String> { - let Some(preset) = presets.iter().find(|p| p.id == preset_id) else { - let known: Vec<&str> = presets.iter().map(|p| p.id.as_str()).collect(); + let Some(principle) = principles.iter().find(|p| p.id == principle_id) else { + let known: Vec<&str> = principles.iter().map(|p| p.id.as_str()).collect(); bail!( - "unknown preset '{preset_id}'. Known presets: {}", + "unknown principle '{principle_id}'. Known principles: {}", known.join(", ") ); }; - let reco = reco_for(level, &preset.sort_metric); - // For the cycle (ADP) preset the unit is a whole cycle group, not a node: + let reco = reco_for(level, &principle.sort_metric); + // For the cycle (ADP) principle the unit is a whole cycle group, not a node: // `--top` counts CYCLES (default 1 — the single biggest chain), and every - // member of each selected cycle is listed. Other presets rank nodes, and + // member of each selected cycle is listed. Other principles rank nodes, and // the default count = the active tier's size (≥ 1). A cycle is a global unit, // so `--focus-path` only narrows the node-ranked (non-cycle) lists. - let is_cycle = preset.sort_metric == "cycle"; + let is_cycle = principle.sort_metric == "cycle"; let cycle_groups = if is_cycle { top_cycle_groups(level, top.unwrap_or(1)) } else { @@ -62,31 +62,31 @@ pub fn compose_prompt( // 1. Principle intent + summary + link + task protocol. // Scaffolding prose (intro / doc-note / task protocol / focus) is DATA from - // the snapshot's `prompt` template; only the Markdown skeleton + the preset's + // 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 <id>` command (no network URL). let mut head = String::new(); - head.push_str(&format!("# {}\n\n", preset.title)); + head.push_str(&format!("# {}\n\n", principle.title)); head.push_str(&tmpl.intro); head.push_str("\n\n## Summary\n\n"); - head.push_str(&preset.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 <id>` command rather than a network URL. - if preset.doc_url.is_some() { - head.push_str(&tmpl.doc_note.replace("{id}", preset_id)); + if principle.doc_url.is_some() { + head.push_str(&tmpl.doc_note.replace("{id}", principle_id)); head.push_str("\n\n"); } head.push_str("## Task\n\n"); for line in &tmpl.task { - head.push_str(&line.replace("{id}", preset_id)); + head.push_str(&line.replace("{id}", principle_id)); head.push('\n'); } head.push('\n'); head.push_str(&tmpl.focus); parts.push(head); - // 2. The offending modules, ordered by the preset's metric (or listed as a + // 2. The offending modules, ordered by the principle's metric (or listed as a // cycle for cycle-based principles), each annotated with its value. if !modules.is_empty() { if is_cycle { @@ -125,7 +125,7 @@ pub fn compose_prompt( } parts.push(s.trim_end().to_string()); } else { - let m = &preset.sort_metric; + 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 <id>`). @@ -139,7 +139,7 @@ pub fn compose_prompt( // Summary above — true for the metric lens, whose summary IS the // metric's description, so it would otherwise print twice. if let Some(d) = &spec.description - && d != &preset.prompt + && d != &principle.prompt { s.push_str(d); s.push_str("\n\n"); @@ -159,7 +159,7 @@ pub fn compose_prompt( } } - // 3. The preset's connection lists (only those with edges), endpoints as paths. + // 3. The principle's connection lists (only those with edges), endpoints as paths. let module_ids: std::collections::HashSet<&str> = modules.iter().map(|n| n.id.as_str()).collect(); let internal: std::collections::HashSet<&str> = level @@ -230,7 +230,7 @@ pub fn compose_prompt( parts.push(s); }; - let wants = |c: &str| preset.connections.iter().any(|x| x == c); + let wants = |c: &str| principle.connections.iter().any(|x| x == c); if wants("common") { let inner: Vec<_> = local_edges .iter() diff --git a/crates/code-ranker-cli/src/recommend/scorecard.rs b/crates/code-ranker-cli/src/recommend/scorecard.rs index dcc362bc..aeda2c70 100644 --- a/crates/code-ranker-cli/src/recommend/scorecard.rs +++ b/crates/code-ranker-cli/src/recommend/scorecard.rs @@ -1,5 +1,5 @@ //! The console triage scorecard behind the `scorecard` report format — a -//! per-principle table mirroring the viewer's per-preset badges, plus the worst +//! per-principle table mirroring the viewer's per-principle badges, plus the worst //! modules overall. use super::{ @@ -8,7 +8,7 @@ use super::{ }; use anyhow::Result; use code_ranker_graph::level_graph::LevelGraph; -use code_ranker_plugin_api::{Preset, node::Node}; +use code_ranker_plugin_api::{Principle, node::Node}; /// One metric (or cycle) breach on a node, with its tier. struct Breach { @@ -92,7 +92,7 @@ fn node_breaches( pub fn render_scorecard( plugin: &str, level: &LevelGraph, - presets: &[Preset], + principles: &[Principle], severities: &[Severity], top: Option<usize>, focus: Option<&super::Focus>, @@ -107,17 +107,17 @@ pub fn render_scorecard( // `--focus` picks the lens. A metric frames the scorecard by that metric alone // (no principle rows — the worst-modules list carries the ranking); a principle - // shows just that preset's row; without it, the full per-principle triage. The + // shows just that principle's row; without it, the full per-principle triage. The // metric the worst-modules list ranks by is the focused metric, the focused // principle's `sort_metric`, or none (a breach-ranked list). - let (shown_presets, narrow): (Vec<&Preset>, Option<&str>) = match focus { + let (shown_principles, narrow): (Vec<&Principle>, Option<&str>) = match focus { Some(super::Focus::Metric(m)) => (Vec::new(), Some(m.as_str())), Some(super::Focus::Principle(id)) => { - let p: Vec<&Preset> = presets.iter().filter(|p| &p.id == id).collect(); + let p: Vec<&Principle> = principles.iter().filter(|p| &p.id == id).collect(); let m = p.first().map(|p| p.sort_metric.as_str()); (p, m) } - None => (presets.iter().collect(), None), + None => (principles.iter().collect(), None), }; let mut out = String::new(); @@ -131,7 +131,7 @@ pub fn render_scorecard( } // ── Per-principle table ────────────────────────────────────────────────── - let mut rows = principle_rows(level, &shown_presets, narrow, want_warning, want_info); + let mut rows = principle_rows(level, &shown_principles, narrow, want_warning, want_info); rows.sort_by(|a, b| b.warn.cmp(&a.warn).then(b.info.cmp(&a.info))); if rows.is_empty() && focus.is_none() { @@ -184,18 +184,18 @@ fn metric_focus_label(level: &LevelGraph, m: &str) -> String { } } -/// Build the per-principle table rows from the shown presets. +/// Build the per-principle table rows from the shown principles. fn principle_rows( level: &LevelGraph, - shown_presets: &[&Preset], + shown_principles: &[&Principle], narrow: Option<&str>, want_warning: bool, want_info: bool, ) -> Vec<Row> { let mut rows: Vec<Row> = Vec::new(); - for p in shown_presets { + for p in shown_principles { let reco = reco_for(level, &p.sort_metric); - // Skip presets with nothing in the selected tiers (unless narrowed). + // Skip principles with nothing in the selected tiers (unless narrowed). let in_scope = (want_warning && reco.warning_count > 0) || (want_info && reco.info_count > 0); if narrow.is_none() && !in_scope { @@ -220,8 +220,8 @@ fn principle_rows( } /// The "top module" cell for a principle row: the worst-ranked module under the -/// preset's metric, annotated with the metric value (or `(cycle)` / a bare path). -fn principle_top_module(level: &LevelGraph, p: &Preset, reco: &super::Reco) -> String { +/// principle's metric, annotated with the metric value (or `(cycle)` / a bare path). +fn principle_top_module(level: &LevelGraph, p: &Principle, reco: &super::Reco) -> String { match reco.sorted.first() { Some(n) if p.sort_metric == "cycle" => format!("{} (cycle)", clean_path(&n.id)), Some(n) => match num(n, &p.sort_metric) { diff --git a/crates/code-ranker-cli/src/recommend_test.rs b/crates/code-ranker-cli/src/recommend_test.rs index 50d50c42..bb293a6b 100644 --- a/crates/code-ranker-cli/src/recommend_test.rs +++ b/crates/code-ranker-cli/src/recommend_test.rs @@ -103,7 +103,7 @@ fn reco_for_cycle_uses_cycle_members() { } #[test] -fn worst_preset_picks_most_violations() { +fn worst_principle_picks_most_violations() { let level = level_with(vec![file_node( "{target}/a.rs", &[ @@ -112,8 +112,8 @@ fn worst_preset_picks_most_violations() { ("cycle", AttrValue::Str("mutual".into())), ], )]); - let presets = vec![ - Preset { + let principles = vec![ + Principle { id: "SRP".into(), label: "SRP".into(), title: "SRP — x".into(), @@ -122,7 +122,7 @@ fn worst_preset_picks_most_violations() { sort_metric: "sloc".into(), connections: vec![], }, - Preset { + Principle { id: "ADP".into(), label: "ADP".into(), title: "ADP — x".into(), @@ -133,7 +133,7 @@ fn worst_preset_picks_most_violations() { }, ]; // SRP: sloc 10 → 0 breaches; ADP: cycle → 1. ADP wins. - assert_eq!(worst_preset(&level, &presets).as_deref(), Some("ADP")); + assert_eq!(worst_principle(&level, &principles).as_deref(), Some("ADP")); } #[test] @@ -167,7 +167,7 @@ fn compose_prompt_cycle_lists_modules_and_connections() { line: None, attrs: Default::default(), }); - let presets = vec![Preset { + let principles = vec![Principle { id: "ADP".into(), label: "ADP".into(), title: "ADP — Acyclic".into(), @@ -178,7 +178,7 @@ fn compose_prompt_cycle_lists_modules_and_connections() { }]; let md = compose_prompt( &level, - &presets, + &principles, &code_ranker_graph::prompt_template(), "ADP", Severity::Auto, @@ -208,7 +208,7 @@ fn compose_prompt_cycle_lists_modules_and_connections() { assert!(md.contains("`a.rs` → `b.rs` (uses)"), "edge line"); assert!( md.contains("191019-ADP.md") || md.contains("-ADP.md"), - "save-report name carries preset id" + "save-report name carries principle id" ); } @@ -262,7 +262,7 @@ fn compose_prompt_metric_orders_and_respects_top() { ], ), ]); - let presets = vec![Preset { + let principles = vec![Principle { id: "SRP".into(), label: "SRP".into(), title: "SRP — Single".into(), @@ -273,7 +273,7 @@ fn compose_prompt_metric_orders_and_respects_top() { }]; let md = compose_prompt( &level, - &presets, + &principles, &code_ranker_graph::prompt_template(), "SRP", Severity::Warning, @@ -296,9 +296,9 @@ fn compose_prompt_metric_orders_and_respects_top() { } #[test] -fn compose_prompt_unknown_preset_errors() { +fn compose_prompt_unknown_principle_errors() { let level = level_with(vec![]); - let presets = vec![Preset { + let principles = vec![Principle { id: "ADP".into(), label: "ADP".into(), title: "t".into(), @@ -309,7 +309,7 @@ fn compose_prompt_unknown_preset_errors() { }]; let err = compose_prompt( &level, - &presets, + &principles, &code_ranker_graph::prompt_template(), "NOPE", Severity::Auto, @@ -317,7 +317,7 @@ fn compose_prompt_unknown_preset_errors() { &[], ) .unwrap_err(); - assert!(format!("{err}").contains("unknown preset 'NOPE'")); + assert!(format!("{err}").contains("unknown principle 'NOPE'")); } #[test] @@ -338,8 +338,8 @@ fn scorecard_shows_principle_and_worst_modules() { ], ), ]); - let presets = vec![ - Preset { + let principles = vec![ + Principle { id: "ADP".into(), label: "ADP".into(), title: "ADP — Acyclic Dependencies".into(), @@ -348,7 +348,7 @@ fn scorecard_shows_principle_and_worst_modules() { sort_metric: "cycle".into(), connections: vec![], }, - Preset { + Principle { id: "SRP".into(), label: "SRP".into(), title: "SRP — Single Responsibility".into(), @@ -361,7 +361,7 @@ fn scorecard_shows_principle_and_worst_modules() { let sc = render_scorecard( "rust", &level, - &presets, + &principles, &[Severity::Warning, Severity::Info], None, None, @@ -388,9 +388,9 @@ fn scorecard_shows_principle_and_worst_modules() { ); } -/// A cycle preset for the narrowed-scorecard tests. -fn adp_preset() -> Preset { - Preset { +/// A cycle principle for the narrowed-scorecard tests. +fn adp_principle() -> Principle { + Principle { id: "ADP".into(), label: "ADP".into(), title: "ADP — Acyclic Dependencies".into(), @@ -401,8 +401,8 @@ fn adp_preset() -> Preset { } } -fn srp_preset() -> Preset { - Preset { +fn srp_principle() -> Principle { + Principle { id: "SRP".into(), label: "SRP".into(), title: "SRP — Single Responsibility".into(), @@ -413,7 +413,7 @@ fn srp_preset() -> Preset { } } -/// Narrowing on a metric preset lists that metric's ranked modules under +/// Narrowing on a metric focus lists that metric's ranked modules under /// WORST MODULES (the `narrow.is_some()` non-cycle branch). #[test] fn scorecard_narrowed_metric_lists_ranked_modules() { @@ -424,7 +424,7 @@ fn scorecard_narrowed_metric_lists_ranked_modules() { let sc = render_scorecard( "rust", &level, - &[srp_preset()], + &[srp_principle()], &[Severity::Warning], Some(2), Some(&Focus::Metric("sloc".into())), @@ -443,7 +443,7 @@ fn scorecard_narrowed_metric_lists_ranked_modules() { ); } -/// Narrowing on the cycle (ADP) preset lists every member of the top cycle +/// 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] fn scorecard_narrowed_cycle_lists_all_members() { @@ -470,7 +470,7 @@ fn scorecard_narrowed_cycle_lists_all_members() { let sc = render_scorecard( "rust", &level, - &[adp_preset()], + &[adp_principle()], &[Severity::Warning], None, Some(&Focus::Metric("cycle".into())), @@ -491,11 +491,11 @@ fn scorecard_narrowed_cycle_lists_all_members() { #[test] fn resolve_focus_unknown_name_errors() { let level = level_with(vec![file_node("{target}/a.rs", &[])]); - let err = resolve_focus(&level, &[srp_preset()], "zzz") + let err = resolve_focus(&level, &[srp_principle()], "zzz") .unwrap_err() .to_string(); assert!( - err.contains("unknown --focus-rule 'zzz'"), + err.contains("unknown --focus 'zzz'"), "names bad focus: {err}" ); assert!( @@ -509,19 +509,19 @@ fn resolve_focus_unknown_name_errors() { #[test] fn resolve_focus_picks_metric_or_principle() { let level = level_with(vec![file_node("{target}/a.rs", &[])]); - let presets = [srp_preset()]; + let principles = [srp_principle()]; assert_eq!( - resolve_focus(&level, &presets, "HK").unwrap(), + resolve_focus(&level, &principles, "HK").unwrap(), Focus::Metric("hk".into()), "metric key matched case-insensitively" ); assert_eq!( - resolve_focus(&level, &presets, "SRP").unwrap(), + resolve_focus(&level, &principles, "SRP").unwrap(), Focus::Principle("SRP".into()), "principle id matched" ); assert_eq!( - resolve_focus(&level, &presets, "threshold.file.hk").unwrap(), + resolve_focus(&level, &principles, "threshold.file.hk").unwrap(), Focus::Metric("hk".into()), "full threshold rule id maps to its metric" ); @@ -547,7 +547,7 @@ fn scorecard_info_tier_and_cycle_in_rest() { let sc = render_scorecard( "rust", &level, - &[srp_preset()], + &[srp_principle()], &[Severity::Warning, Severity::Info], None, None, @@ -574,7 +574,7 @@ fn scorecard_reports_no_breaches_when_clean() { let sc = render_scorecard( "rust", &level, - &[srp_preset()], + &[srp_principle()], &[Severity::Warning], None, None, @@ -588,7 +588,7 @@ fn scorecard_reports_no_breaches_when_clean() { } /// A two-cycle level: builds nodes + two `CycleGroup`s, returned ready for the -/// ADP (cycle) preset. +/// ADP (cycle) principle. fn two_cycle_level() -> LevelGraph { let mut level = level_with(vec![ file_node( @@ -636,7 +636,7 @@ fn compose_prompt_lists_multiple_cycles() { let level = two_cycle_level(); let md = compose_prompt( &level, - &[adp_preset()], + &[adp_principle()], &code_ranker_graph::prompt_template(), "ADP", Severity::Auto, @@ -662,7 +662,7 @@ fn scorecard_narrowed_cycle_top_n_header() { let sc = render_scorecard( "rust", &level, - &[adp_preset()], + &[adp_principle()], &[Severity::Warning], Some(2), Some(&Focus::Metric("cycle".into())), @@ -682,7 +682,7 @@ fn scorecard_narrowed_cycle_with_none_says_none() { let sc = render_scorecard( "rust", &level, - &[adp_preset()], + &[adp_principle()], &[Severity::Warning], None, Some(&Focus::Metric("cycle".into())), @@ -699,7 +699,7 @@ fn scorecard_clips_long_principle_name() { "{target}/a.rs", &[("hk", AttrValue::Float(2000.0))], )]); - let preset = Preset { + let principle = Principle { id: "LONG".into(), label: "LONG".into(), title: "LONG — A Very Long Principle Name That Exceeds The Column".into(), @@ -711,7 +711,7 @@ fn scorecard_clips_long_principle_name() { let sc = render_scorecard( "rust", &level, - &[preset], + &[principle], &[Severity::Warning], None, None, @@ -727,17 +727,17 @@ fn parse_severity_rejects_garbage() { assert!(parse_severity("nope").is_err()); } -/// `synth_metric_preset` frames a metric as its own "principle": title from +/// `synth_metric_principle` frames a metric as its own "principle": title from /// label+name, summary from description, `doc_url` extracted from the remediation /// URL, and in/out/common connections for a coupling metric (none otherwise). #[test] -fn synth_metric_preset_frames_metric() { +fn synth_metric_principle_frames_metric() { let mut hk = AttributeSpec::new(ValueType::Float, "HK"); hk.short = Some("HK".into()); hk.name = Some("Henry–Kafura".into()); hk.description = Some("coupling × size".into()); hk.group = Some("coupling".into()); - hk.remediation = Some("Download and follow https://x/HK.md please".into()); + hk.remediation = Some("Run `code-ranker report --doc HK` and follow its instructions.".into()); let mut sloc = AttributeSpec::new(ValueType::Int, "SLOC"); sloc.description = Some("source lines".into()); let mut na: BTreeMap<String, AttributeSpec> = BTreeMap::new(); @@ -748,15 +748,15 @@ fn synth_metric_preset_frames_metric() { ..Default::default() }; - let p = synth_metric_preset(&level, "hk"); + let p = synth_metric_principle(&level, &[], "hk"); assert_eq!(p.id, "hk"); assert_eq!(p.sort_metric, "hk"); assert_eq!(p.title, "HK — Henry–Kafura"); assert_eq!(p.prompt, "coupling × size"); assert_eq!( p.doc_url.as_deref(), - Some("https://x/HK.md"), - "url from remediation" + Some("HK"), + "doc id from the remediation's --doc reference" ); assert_eq!( p.connections, @@ -764,14 +764,43 @@ fn synth_metric_preset_frames_metric() { "coupling → connections" ); - let q = synth_metric_preset(&level, "sloc"); + let q = synth_metric_principle(&level, &[], "sloc"); assert_eq!(q.title, "SLOC", "no `name` → title is the label"); assert!(q.connections.is_empty(), "non-coupling → no connections"); assert!(q.doc_url.is_none(), "no remediation URL → no doc link"); } +/// `synth_metric_principle("cycle", …)` borrows the ADP principle (the one whose +/// `sort_metric` is `cycle`) so the metric-lens prompt reads like ADP; with no such +/// principle present it falls through to generic metric framing. +#[test] +fn synth_metric_principle_cycle_borrows_adp() { + let adp = Principle { + id: "ADP".into(), + label: "ADP".into(), + title: "ADP — Acyclic Dependencies Principle".into(), + prompt: "Break the cycles.".into(), + doc_url: Some("https://x/ADP.md".into()), + sort_metric: "cycle".into(), + connections: vec!["common".into()], + }; + let level = LevelGraph::default(); + + let p = synth_metric_principle(&level, std::slice::from_ref(&adp), "cycle"); + assert_eq!(p.id, "cycle"); + assert_eq!(p.sort_metric, "cycle"); + assert_eq!(p.title, adp.title, "borrows ADP's title"); + assert_eq!(p.prompt, adp.prompt, "borrows ADP's prompt body"); + assert_eq!(p.connections, adp.connections, "borrows ADP's connections"); + assert_eq!(p.doc_url, adp.doc_url, "borrows ADP's doc"); + + // No ADP-like principle present → generic metric framing (label is the key). + let g = synth_metric_principle(&level, &[], "cycle"); + assert_eq!(g.title, "cycle"); +} + /// The metric lens must not print the metric description twice — once is the -/// Summary (the synth preset's `prompt`), so the modules section drops it. +/// Summary (the synth principle's `prompt`), so the modules section drops it. #[test] fn compose_prompt_metric_lens_omits_duplicate_description() { let desc = "coupling and size, quadratic in fan"; @@ -786,10 +815,10 @@ fn compose_prompt_metric_lens_omits_duplicate_description() { nodes: vec![file_node("{target}/a.rs", &[("hk", AttrValue::Float(9.0))])], ..Default::default() }; - let preset = synth_metric_preset(&level, "hk"); // preset.prompt == desc + let principle = synth_metric_principle(&level, &[], "hk"); // principle.prompt == desc let md = compose_prompt( &level, - &[preset], + &[principle], &code_ranker_graph::prompt_template(), "hk", Severity::Auto, @@ -827,10 +856,10 @@ fn in_focus_matches_file_and_folder() { assert!(!in_focus(&n, &["crates/b".to_string()]), "outside the path"); } -/// A principle focus shows only that preset's row (others hidden) and ranks the +/// A principle focus shows only that principle's row (others hidden) and ranks the /// worst modules by its `sort_metric`. #[test] -fn scorecard_focus_principle_shows_only_that_preset() { +fn scorecard_focus_principle_shows_only_that_principle() { let level = level_with(vec![file_node( "{target}/big.rs", &[ @@ -838,11 +867,11 @@ fn scorecard_focus_principle_shows_only_that_preset() { ("sloc", AttrValue::Int(300)), ], )]); - let presets = [srp_preset(), adp_preset()]; + let principles = [srp_principle(), adp_principle()]; let sc = render_scorecard( "rust", &level, - &presets, + &principles, &[Severity::Warning, Severity::Info], None, Some(&Focus::Principle("SRP".into())), @@ -885,7 +914,7 @@ fn compose_prompt_single_focus_abbreviates_in_and_out_edges() { line: Some(3), attrs: Default::default(), }); - let preset = Preset { + let principle = Principle { id: "HK".into(), label: "HK".into(), title: "HK — Hotspot".into(), @@ -896,7 +925,7 @@ fn compose_prompt_single_focus_abbreviates_in_and_out_edges() { }; let md = compose_prompt( &level, - &[preset], + &[principle], &code_ranker_graph::prompt_template(), "HK", Severity::Auto, diff --git a/crates/code-ranker-cli/src/report.rs b/crates/code-ranker-cli/src/report.rs index f7cfdd6d..544b9095 100644 --- a/crates/code-ranker-cli/src/report.rs +++ b/crates/code-ranker-cli/src/report.rs @@ -29,7 +29,7 @@ pub(crate) struct ReportOutputs { pub(crate) struct ReportReco { /// Focus the `scorecard` / `prompt` on one axis — a metric / rule id (`hk`, /// `threshold.file.hk`) or a principle id (`LSP`). Resolved against both. - pub(crate) focus_rule: Option<String>, + pub(crate) focus: Option<String>, /// Restrict the ranked modules to these repo-relative paths (folder = subtree). pub(crate) focus_path: Vec<String>, pub(crate) severity: Vec<String>, @@ -78,13 +78,13 @@ pub(crate) fn run_report( } if !want_prompt && !want_scorecard - && (reco.focus_rule.is_some() + && (reco.focus.is_some() || !reco.focus_path.is_empty() || !reco.severity.is_empty() || reco.top.is_some()) { anyhow::bail!( - "--focus-rule/--focus-path/--severity/--top apply only with --output.prompt or --output.scorecard" + "--focus/--focus-path/--severity/--top apply only with --output.prompt or --output.scorecard" ); } // `--severity` steers the scorecard only (tiers are a triage concern). @@ -252,20 +252,24 @@ fn run_direct(args: &AnalyzeArgs, reco: &ReportReco) -> Result<()> { .graphs .get("files") .context("snapshot has no `files` level to build a prompt from")?; - let focus = recommend::resolve_focus(level, &snap.presets, id)?; - let synth; // holds the metric-lens preset for the borrow below - let (presets_for_prompt, preset_id): (&[recommend::Preset], String) = match &focus { + let focus = recommend::resolve_focus(level, &snap.principles, id)?; + let synth; // holds the metric-lens principle for the borrow below + let (principles_for_prompt, principle_id): (&[recommend::Principle], String) = match &focus { recommend::Focus::Metric(m) => { - synth = [recommend::synth_metric_preset(level, m)]; + synth = [recommend::synth_metric_principle( + level, + &snap.principles, + m, + )]; (&synth, m.clone()) } - recommend::Focus::Principle(pid) => (&snap.presets, pid.clone()), + recommend::Focus::Principle(pid) => (&snap.principles, pid.clone()), }; let md = recommend::compose_prompt( level, - presets_for_prompt, + principles_for_prompt, &snap.prompt, - &preset_id, + &principle_id, recommend::Severity::Auto, reco.top, &reco.focus_path, @@ -296,43 +300,48 @@ fn write_recommendations( .get("files") .context("snapshot has no `files` level to build recommendations from")?; - // Resolve `--focus-rule` once against both namespaces (metric / rule id / + // Resolve `--focus` once against both namespaces (metric / rule id / // principle id). `--focus-path` then narrows the ranked modules to a subtree. let focus = reco - .focus_rule + .focus .as_deref() - .map(|n| recommend::resolve_focus(level, &snap.presets, n)) + .map(|n| recommend::resolve_focus(level, &snap.principles, n)) .transpose()?; if want_prompt { - // Metric focus frames the prompt by a synthesized metric "preset" (no SOLID - // principle); a principle focus targets that preset; no focus auto-targets + // Metric focus frames the prompt by a synthesized metric "principle" (no SOLID + // principle); a principle focus targets that principle; no focus auto-targets // the worst-violating principle. `--top 1` is validated up front, so `Auto` // tier is irrelevant. - let synth; // holds the metric-lens preset, if any, for the borrow below - let (presets_for_prompt, preset_id): (&[recommend::Preset], String) = match &focus { + let synth; // holds the metric-lens principle, if any, for the borrow below + let (principles_for_prompt, principle_id): (&[recommend::Principle], String) = match &focus + { Some(recommend::Focus::Metric(m)) => { - synth = [recommend::synth_metric_preset(level, m)]; + synth = [recommend::synth_metric_principle( + level, + &snap.principles, + m, + )]; (&synth, m.clone()) } - Some(recommend::Focus::Principle(id)) => (&snap.presets, id.clone()), + Some(recommend::Focus::Principle(id)) => (&snap.principles, id.clone()), None => ( - &snap.presets, - recommend::worst_preset(level, &snap.presets) - .context("no presets in the snapshot to recommend from")?, + &snap.principles, + recommend::worst_principle(level, &snap.principles) + .context("no principles in the snapshot to recommend from")?, ), }; let md = recommend::compose_prompt( level, - presets_for_prompt, + principles_for_prompt, &snap.prompt, - &preset_id, + &principle_id, recommend::Severity::Auto, reco.top, &reco.focus_path, )?; - let dest = - render_name(prompt_tpl, target, commit, generated_at).replace("{preset}", &preset_id); + let dest = render_name(prompt_tpl, target, commit, generated_at) + .replace("{principle}", &principle_id); write_artifact(&dest, &md, "prompt")?; } @@ -348,7 +357,7 @@ fn write_recommendations( let txt = recommend::render_scorecard( &snap.plugin, level, - &snap.presets, + &snap.principles, &severities, reco.top, focus.as_ref(), diff --git a/crates/code-ranker-cli/src/templates.rs b/crates/code-ranker-cli/src/templates.rs index 0721a60c..efe1d7e3 100644 --- a/crates/code-ranker-cli/src/templates.rs +++ b/crates/code-ranker-cli/src/templates.rs @@ -19,24 +19,50 @@ fn corpus_doc(rel: &str) -> Option<&'static str> { } /// The corpus path a doc id resolves to, as `<lang>/<ID>.md` — read from the -/// already-resolved `doc_url` of a matching preset (principle docs) or the +/// already-resolved `doc_url` of a matching principle (principle docs) or the /// `remediation` URL of a matching metric (coupling/complexity docs). Both encode /// the post-fallback corpus location (own `<lang>/` or the shared `base/`), so the /// override key namespace lines up with where the doc actually lives. fn doc_rel_path(snap: &Snapshot, id: &str) -> Option<String> { - // Principle preset (case-insensitive id, e.g. `SRP`, `adp`). - if let Some(p) = snap.presets.iter().find(|p| p.id.eq_ignore_ascii_case(id)) + // `cycle` is the ADP principle's metric lens (not a real node attribute), so + // its doc IS the ADP doc — resolve it through the ADP principle below. + let id = if id.eq_ignore_ascii_case("cycle") { + "ADP" + } else { + id + }; + // Principle (case-insensitive id, e.g. `SRP`, `adp`). + if let Some(p) = snap + .principles + .iter() + .find(|p| p.id.eq_ignore_ascii_case(id)) && let Some(url) = &p.doc_url && let Some(rel) = url_tail(url) { return Some(rel); } - // Metric doc (attribute key is lowercase, e.g. `hk`, `cyclomatic`). + // Metric doc: the attribute is found by its (lowercase) key, but the canonical + // doc filename comes from the `--doc <ID>` token in its `remediation` (e.g. key + // `fan_in` → doc `Fan-in.md`). Metric docs live in the neutral `base/` corpus. let key = id.to_ascii_lowercase(); - let files = snap.graphs.get("files")?; - let spec = files.node_attributes.get(&key)?; - let rem = spec.remediation.as_ref()?; - url_tail(rem) + if let Some(rel) = snap + .graphs + .get("files") + .and_then(|f| f.node_attributes.get(&key)) + .and_then(|spec| spec.remediation.as_deref()) + .and_then(crate::recommend::doc_ref) + .map(|doc| format!("base/{doc}.md")) + { + return Some(rel); + } + // Fallback: any base corpus doc addressable by its filename stem + // (case-insensitive) — covers docs that are neither a principle nor a metric: + // `Fan-in` / `Fan-out` (key is `fan_in`, not the hyphenated filename), the + // `metrics` reference, and the `AI` overview index. + CORPUS.iter().find_map(|(rel, _)| { + let stem = rel.strip_prefix("base/")?.strip_suffix(".md")?; + stem.eq_ignore_ascii_case(id).then(|| (*rel).to_string()) + }) } /// Extract the `<lang>/<ID>.md` tail of a corpus URL (`…/languages/base/HK.md` @@ -55,6 +81,93 @@ fn is_manifest(md: &str) -> bool { md.contains("<!-- doc:base") } +/// Marker placed in `base/AI.md`; expands to a one-paragraph summary of every +/// other base doc (its `**TL;DR**` paragraph, or the first prose paragraph when a +/// doc has none), so the AI overview always lists the current catalog. +const TLDR_INDEX_MARKER: &str = "<!-- doc:tldr-index -->"; + +/// 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<String> { + let lines: Vec<&str> = md.lines().collect(); + let para_from = |start: usize| -> Option<String> { + 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) +} + +/// 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 { + let mut entries: Vec<(String, String)> = CORPUS + .iter() + .filter_map(|(rel, contents)| { + let stem = rel.strip_prefix("base/")?.strip_suffix(".md")?; + if stem.eq_ignore_ascii_case("AI") { + return None; + } + let title = contents + .lines() + .find_map(|l| l.strip_prefix("# ")) + .unwrap_or(stem) + .trim(); + let head = format!("### {title}\n\nFull doc: `code-ranker report --doc {stem}`"); + let entry = match doc_summary(contents) { + Some(s) => format!("{head}\n\n{s}"), + None => head, + }; + Some((stem.to_ascii_lowercase(), entry)) + }) + .collect(); + entries.sort_by(|a, b| a.0.cmp(&b.0)); + entries + .into_iter() + .map(|(_, e)| e) + .collect::<Vec<_>>() + .join("\n\n") +} + +/// Replace a `<!-- doc:tldr-index -->` marker with the generated catalog; a no-op +/// for docs that don't carry it. +fn expand_tldr_index(md: &str) -> String { + if md.contains(TLDR_INDEX_MARKER) { + md.replace(TLDR_INDEX_MARKER, &tldr_index()) + } else { + md.to_string() + } +} + +pub(crate) fn resolve_doc( + snap: &Snapshot, + templates: &TemplatesConfig, + id: &str, +) -> Result<String> { + Ok(expand_tldr_index(&resolve_doc_raw(snap, templates, id)?)) +} + /// Resolve the Markdown for doc `id` against the active snapshot. In order: /// 1. a `[templates.languages.<lang>.<ID>]` override → that file verbatim (the /// user supplies the final doc, "as if it were `languages/<lang>/<ID>.md`"); @@ -63,14 +176,10 @@ fn is_manifest(md: &str) -> bool { /// 3. the embedded `<lang>/<ID>.md` (a full standalone doc); else the `base/<ID>.md` /// fallback. /// -/// `id` is a preset id (`SRP`) or a metric key (`hk`). -pub(crate) fn resolve_doc( - snap: &Snapshot, - templates: &TemplatesConfig, - id: &str, -) -> Result<String> { +/// `id` is a principle id (`SRP`) or a metric key (`hk`). +fn resolve_doc_raw(snap: &Snapshot, templates: &TemplatesConfig, id: &str) -> Result<String> { let rel = doc_rel_path(snap, id).with_context(|| { - let known: Vec<&str> = snap.presets.iter().map(|p| p.id.as_str()).collect(); + let known: Vec<&str> = snap.principles.iter().map(|p| p.id.as_str()).collect(); format!( "no principle or metric doc for {id:?}. Known principles: {}", known.join(", ") @@ -129,8 +238,10 @@ pub(crate) fn build_corpus(out_dir: &std::path::Path) -> Result<usize> { .with_context(|| format!("manifest {rel} has no base/{stem}.md"))?; crate::compose::compose(contents, base, lang_display(lang))? } else { - // `base/*` and full language docs are published verbatim. - contents.to_string() + // `base/*` and full language docs are published verbatim (the AI index's + // `<!-- doc:tldr-index -->` marker is expanded so Pages ships the catalog, + // not the raw marker). + expand_tldr_index(contents) }; let dest = out_dir.join(rel); diff --git a/crates/code-ranker-cli/src/templates_test.rs b/crates/code-ranker-cli/src/templates_test.rs index 5c64f84a..c1940b06 100644 --- a/crates/code-ranker-cli/src/templates_test.rs +++ b/crates/code-ranker-cli/src/templates_test.rs @@ -1,13 +1,13 @@ use super::*; use code_ranker_graph::level_graph::LevelGraph; -use code_ranker_plugin_api::Preset; +use code_ranker_plugin_api::Principle; use code_ranker_plugin_api::attrs::ValueType; use code_ranker_plugin_api::level::AttributeSpec; use std::collections::BTreeMap; /// A snapshot carrying just the bits `resolve_doc`/`doc_rel_path` read: -/// the principle presets and the `files` level's node-attribute specs. -fn snap(presets: Vec<Preset>, files_attrs: BTreeMap<String, AttributeSpec>) -> Snapshot { +/// the principles and the `files` level's node-attribute specs. +fn snap(principles: Vec<Principle>, files_attrs: BTreeMap<String, AttributeSpec>) -> Snapshot { let files = LevelGraph { node_attributes: files_attrs, ..Default::default() @@ -25,13 +25,13 @@ fn snap(presets: Vec<Preset>, files_attrs: BTreeMap<String, AttributeSpec>) -> S None, vec![], graphs, - presets, + principles, Default::default(), ) } -fn preset(id: &str, doc_url: &str) -> Preset { - Preset { +fn principle(id: &str, doc_url: &str) -> Principle { + Principle { id: id.to_string(), label: id.to_string(), title: id.to_string(), @@ -51,7 +51,10 @@ fn metric_spec(remediation: &str) -> AttributeSpec { #[test] fn resolve_doc_serves_base_fallback() { let s = snap( - vec![preset("SRP", "https://x/blob/main/languages/base/SRP.md")], + vec![principle( + "SRP", + "https://x/blob/main/languages/base/SRP.md", + )], BTreeMap::new(), ); let doc = resolve_doc(&s, &TemplatesConfig::default(), "SRP").unwrap(); @@ -63,7 +66,10 @@ fn resolve_doc_assembles_a_language_manifest() { // rust/ADP.md is a manifest (`<!-- doc:base … -->`), so the resolved doc // is the composition over base/ADP.md, not the raw manifest text. let s = snap( - vec![preset("ADP", "https://x/blob/main/languages/rust/ADP.md")], + vec![principle( + "ADP", + "https://x/blob/main/languages/rust/ADP.md", + )], BTreeMap::new(), ); let doc = resolve_doc(&s, &TemplatesConfig::default(), "ADP").unwrap(); @@ -92,7 +98,10 @@ fn resolve_doc_manifest_uses_base_override_when_present() { .insert("base".to_string(), base_overrides); let s = snap( - vec![preset("ADP", "https://x/blob/main/languages/rust/ADP.md")], + vec![principle( + "ADP", + "https://x/blob/main/languages/rust/ADP.md", + )], BTreeMap::new(), ); let doc = resolve_doc(&s, &templates, "ADP").unwrap(); @@ -116,7 +125,10 @@ fn resolve_doc_override_wins_verbatim() { templates.languages.insert("rust".to_string(), srp); let s = snap( - vec![preset("SRP", "https://x/blob/main/languages/rust/SRP.md")], + vec![principle( + "SRP", + "https://x/blob/main/languages/rust/SRP.md", + )], BTreeMap::new(), ); let doc = resolve_doc(&s, &templates, "SRP").unwrap(); @@ -124,13 +136,32 @@ fn resolve_doc_override_wins_verbatim() { } #[test] -fn resolve_doc_finds_metric_via_remediation_url() { - // No matching preset — the doc resolves through the metric's remediation - // URL instead (lowercased attribute key). +fn resolve_doc_cycle_resolves_to_adp() { + // `cycle` is ADP's metric lens (not a node attribute), so `--doc cycle` serves + // the ADP doc — resolved through the ADP principle, same as `--doc ADP`. + let s = snap( + vec![principle( + "ADP", + "https://x/blob/main/languages/rust/ADP.md", + )], + BTreeMap::new(), + ); + let doc = resolve_doc(&s, &TemplatesConfig::default(), "cycle").unwrap(); + let manifest = corpus_doc("rust/ADP.md").unwrap(); + let base = corpus_doc("base/ADP.md").unwrap(); + let expected = crate::compose::compose(manifest, base, "Rust").unwrap(); + assert_eq!(doc, expected, "`--doc cycle` serves the ADP doc"); +} + +#[test] +fn resolve_doc_finds_metric_via_remediation_doc_ref() { + // No matching principle — the doc resolves through the metric's remediation + // `--doc <ID>` reference (the attribute looked up by its lowercased key, the + // canonical doc filename taken from the `--doc` id). Metric docs live in base/. let mut attrs = BTreeMap::new(); attrs.insert( "hk".to_string(), - metric_spec("See https://x/blob/main/languages/base/HK.md for the fix"), + metric_spec("Run `code-ranker report --doc HK` and follow its instructions."), ); let s = snap(vec![], attrs); let doc = resolve_doc(&s, &TemplatesConfig::default(), "HK").unwrap(); @@ -140,7 +171,10 @@ fn resolve_doc_finds_metric_via_remediation_url() { #[test] fn resolve_doc_unknown_id_errors() { let s = snap( - vec![preset("SRP", "https://x/blob/main/languages/base/SRP.md")], + vec![principle( + "SRP", + "https://x/blob/main/languages/base/SRP.md", + )], BTreeMap::new(), ); let err = resolve_doc(&s, &TemplatesConfig::default(), "ZZZ").unwrap_err(); @@ -202,3 +236,68 @@ fn bare_relative_path_defaults_to_base_folder() { let (lang, file) = "HK.md".split_once('/').unwrap_or(("base", "HK.md")); assert_eq!((lang, file), ("base", "HK.md")); } + +#[test] +fn resolve_doc_ai_index_expands_tldr_marker() { + // The AI overview resolves by filename fallback, and its + // `<!-- doc:tldr-index -->` marker expands to the per-doc catalog. + let s = snap( + vec![principle( + "ADP", + "https://x/blob/main/languages/rust/ADP.md", + )], + BTreeMap::new(), + ); + let doc = resolve_doc(&s, &TemplatesConfig::default(), "AI").unwrap(); + assert!( + doc.contains("code-ranker — AI agent skill"), + "overview head kept" + ); + assert!( + !doc.contains("doc:tldr-index"), + "marker expanded, not left literal" + ); + assert!( + doc.contains("### ADP — Acyclic Dependencies Principle"), + "catalog lists ADP" + ); + assert!( + doc.contains("Full doc: `code-ranker report --doc ADP`"), + "each entry points at its --doc 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" + ); +} + +#[test] +fn resolve_doc_resolves_base_doc_by_filename_stem() { + // Docs that are neither a principle nor a node attribute resolve by their base + // filename stem: hyphenated metric files (key is `fan_in`, file is `Fan-in`) + // and the `metrics` reference. + let s = snap(vec![], BTreeMap::new()); + assert_eq!( + resolve_doc(&s, &TemplatesConfig::default(), "Fan-in").unwrap(), + corpus_doc("base/Fan-in.md").unwrap() + ); + assert_eq!( + resolve_doc(&s, &TemplatesConfig::default(), "metrics").unwrap(), + corpus_doc("base/metrics.md").unwrap() + ); +} + +#[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.") + ); +} diff --git a/crates/code-ranker-cli/tests/e2e.rs b/crates/code-ranker-cli/tests/e2e.rs index 4f5919a1..419c1939 100644 --- a/crates/code-ranker-cli/tests/e2e.rs +++ b/crates/code-ranker-cli/tests/e2e.rs @@ -501,11 +501,11 @@ fn rust_sample_check_focus_path_scopes_gate() { ); } -/// `check --focus-rule` scopes the gate to a rule id: focusing `cycle.mutual` keeps +/// `check --focus` scopes the gate to a rule id: focusing `cycle.mutual` keeps /// the `a ⇄ b` mutual cycle and drops the chain cycle. #[test] -fn rust_sample_check_focus_rule_scopes_gate() { - let (_ok, stdout, stderr) = run_check_capture("rust", &["--focus-rule", "cycle.mutual"]); +fn rust_sample_check_focus_scopes_gate() { + let (_ok, stdout, stderr) = run_check_capture("rust", &["--focus", "cycle.mutual"]); assert!( stdout.contains("1 violation(s)") && stdout.contains("focused on rule cycle.mutual"), "gate scoped to the focused rule: {stdout}{stderr}" @@ -537,7 +537,7 @@ fn rust_sample_check_top_limits_reported_not_exit() { } /// `report --prompt <metric>` composes the prompt through the metric lens (a -/// synthesized metric "preset"), not a SOLID principle — exercising the metric +/// synthesized metric "principle"), not a SOLID principle — exercising the metric /// arm of the standalone `--prompt` path. #[test] fn rust_sample_prompt_flag_targets_metric_lens() { @@ -686,7 +686,7 @@ fn rust_sample_scorecard_triage() { ); } -/// With no `--preset`, the prompt auto-picks the worst-violating principle (ADP +/// With no `--principle`, the prompt auto-picks the worst-violating principle (ADP /// here) and lists the worst cycle's members + their connections — the same /// Markdown the HTML viewer's Prompt Generator emits. The 3-node `chain` SCC /// outranks the 2-node `a ⇄ b` mutual, so it is the cycle shown. @@ -715,7 +715,7 @@ fn rust_sample_prompt_auto_picks_worst_principle() { ); assert!( stdout.contains(".code-ranker/<YYYYMMDD-HHMMSS>-ADP.md"), - "save-report instruction carries the preset id: {stdout}" + "save-report instruction carries the principle id: {stdout}" ); } @@ -755,12 +755,12 @@ fn rust_sample_doc_flag_prints_embedded_markdown() { ); } -/// `--focus-rule <metric>` frames the scorecard by that metric. `--focus-rule cycle` +/// `--focus <metric>` frames the scorecard by that metric. `--focus cycle` /// shows the dependency-cycle members (the ADP view) without the principle table. #[test] fn rust_sample_scorecard_focus_metric() { let (ok, stdout, stderr) = - run_report_capture("rust", &["--output.scorecard", "--focus-rule", "cycle"]); + run_report_capture("rust", &["--output.scorecard", "--focus", "cycle"]); assert!(ok, "focused scorecard run failed: {stderr}"); assert!( stdout.contains("scorecard (rust, 25 files)"), @@ -772,14 +772,14 @@ fn rust_sample_scorecard_focus_metric() { ); } -/// `--focus-rule HK` (a metric, by value) frames the output by the metric itself — -/// no SOLID principle (the Liskov row the hk-ranking preset would otherwise show). +/// `--focus HK` (a metric, by value) frames the output by the metric itself — +/// no SOLID principle (the Liskov row the hk-ranking principle would otherwise show). /// Also accepts the full threshold rule id `threshold.file.hk`. #[test] fn rust_sample_scorecard_focus_metric_hides_principle() { for rule in ["HK", "threshold.file.hk"] { let (ok, stdout, stderr) = - run_report_capture("rust", &["--output.scorecard", "--focus-rule", rule]); + run_report_capture("rust", &["--output.scorecard", "--focus", rule]); assert!(ok, "metric-lens scorecard failed for {rule}: {stderr}"); assert!( stdout.contains("focus: HK"), @@ -800,7 +800,7 @@ fn rust_sample_scorecard_focus_path_scopes_modules() { "rust", &[ "--output.scorecard", - "--focus-rule", + "--focus", "hk", "--focus-path", "src/chain", @@ -817,14 +817,14 @@ fn rust_sample_scorecard_focus_path_scopes_modules() { ); } -/// An unknown `--focus-rule` name is a hard error naming both namespaces. +/// An unknown `--focus` name is a hard error naming both namespaces. #[test] fn rust_sample_scorecard_unknown_focus() { let (ok, _stdout, stderr) = - run_report_capture("rust", &["--output.scorecard", "--focus-rule", "nope"]); + run_report_capture("rust", &["--output.scorecard", "--focus", "nope"]); assert!(!ok, "unknown focus must fail"); assert!( - stderr.contains("unknown --focus-rule 'nope'"), + stderr.contains("unknown --focus 'nope'"), "actionable error: {stderr}" ); } @@ -877,11 +877,8 @@ fn rust_sample_report_rejects_index() { /// The recommendation knobs only apply with a `prompt` / `scorecard` format. #[test] fn rust_sample_report_rejects_stray_reco_flags() { - let (ok, _stdout, stderr) = run_report_capture("rust", &["--focus-rule", "hk"]); - assert!( - !ok, - "--focus-rule without a prompt/scorecard format must fail" - ); + let (ok, _stdout, stderr) = run_report_capture("rust", &["--focus", "hk"]); + assert!(!ok, "--focus without a prompt/scorecard format must fail"); assert!( stderr.contains("apply only with --output.prompt or --output.scorecard"), "actionable error: {stderr}" diff --git a/crates/code-ranker-graph/metrics/builtin.toml b/crates/code-ranker-graph/metrics/builtin.toml index 510cc6ca..acbb8b88 100644 --- a/crates/code-ranker-graph/metrics/builtin.toml +++ b/crates/code-ranker-graph/metrics/builtin.toml @@ -137,7 +137,7 @@ That nesting penalty is the point — deeply indented logic is what actually str Summed across every function in the file.""" direction = "lower_better" category = "complexity" -remediation = "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/Cognitive.md" +remediation = "Run `code-ranker report --doc Cognitive` and follow its instructions." [ast.exits] value_type = "int" @@ -228,7 +228,7 @@ formula_js = "spaces + branches" direction = "lower_better" category = "complexity" omit_at = 1.0 -remediation = "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/Cyclomatic.md" +remediation = "Run `code-ranker report --doc Cyclomatic` and follow its instructions." [fields.effort] value_type = "float" @@ -331,7 +331,7 @@ formula_js = "sloc * (fan_in * fan_out) ** 2" direction = "lower_better" category = "coupling" abbreviate = true -remediation = "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/HK.md" +remediation = "Run `code-ranker report --doc HK` and follow its instructions." # ── coupling (computed post-walk by annotate_coupling / annotate_cycles) ─────── # Spec-only entries: the VALUES are derived by the graph crate's coupling/cycle @@ -344,14 +344,14 @@ value_type = "int" label = "Fan-in" description = "Many other units depend on this one, making it risky to change and a single point of failure — though some hubs (shared types) carry high fan-in legitimately." category = "coupling" -remediation = "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/Fan-in.md" +remediation = "Run `code-ranker report --doc Fan-in` and follow its instructions." [coupling.fan_out] value_type = "int" label = "Fan-out" description = "This unit depends on many others, so it breaks when any of them change and is hard to test in isolation." category = "coupling" -remediation = "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/Fan-out.md" +remediation = "Run `code-ranker report --doc Fan-out` and follow its instructions." [coupling.fan_out_external] value_type = "int" @@ -372,12 +372,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 = "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/ADP.md" +remediation = "Run `code-ranker report --doc 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 = "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/ADP.md" +remediation = "Run `code-ranker report --doc 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 d444d06a..8257fc93 100644 --- a/crates/code-ranker-graph/metrics/prompt.md +++ b/crates/code-ranker-graph/metrics/prompt.md @@ -5,7 +5,7 @@ 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 preset +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/`. diff --git a/crates/code-ranker-graph/src/snapshot.rs b/crates/code-ranker-graph/src/snapshot.rs index f224320d..656e273e 100644 --- a/crates/code-ranker-graph/src/snapshot.rs +++ b/crates/code-ranker-graph/src/snapshot.rs @@ -9,7 +9,7 @@ use crate::level_graph::LevelGraph; use chrono::{DateTime, Utc}; -use code_ranker_plugin_api::Preset; +use code_ranker_plugin_api::Principle; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -50,9 +50,9 @@ pub struct Snapshot { pub timings: Vec<StageTime>, /// Analysis levels, keyed by level name. Today only `"files"` is produced. pub graphs: BTreeMap<String, LevelGraph>, - /// Prompt-Generator presets (refactoring principles), language-adapted. + /// Prompt-Generator principles (refactoring principles), language-adapted. #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub presets: Vec<Preset>, + pub principles: Vec<Principle>, /// Prompt-Generator scaffolding prose (language-neutral framing), so the CLI /// `prompt` format and the HTML viewer render the same text from one source. #[serde(default)] @@ -82,7 +82,7 @@ impl Snapshot { git: Option<GitInfo>, timings: Vec<StageTime>, graphs: BTreeMap<String, LevelGraph>, - presets: Vec<Preset>, + principles: Vec<Principle>, prompt: code_ranker_plugin_api::PromptTemplate, ) -> Self { Self { @@ -98,7 +98,7 @@ impl Snapshot { git, timings, graphs, - presets, + principles, prompt, } } diff --git a/crates/code-ranker-plugin-api/src/lib.rs b/crates/code-ranker-plugin-api/src/lib.rs index fcdf1d2e..3a1a54f9 100644 --- a/crates/code-ranker-plugin-api/src/lib.rs +++ b/crates/code-ranker-plugin-api/src/lib.rs @@ -42,7 +42,7 @@ pub mod log; pub mod metrics; pub mod node; pub mod plugin; -pub mod preset; +pub mod principle; pub mod report; pub mod toml_merge; @@ -57,7 +57,7 @@ pub use level::{ pub use metrics::{FunctionUnit, MetricInputs}; pub use node::{Node, NodeId}; pub use plugin::{LanguagePlugin, PluginInput, PluginRegistration, registry}; -pub use preset::{Preset, PromptTemplate}; +pub use principle::{Principle, PromptTemplate}; pub use report::{ListPatch, ReportOverride}; use std::collections::BTreeMap; diff --git a/crates/code-ranker-plugin-api/src/plugin.rs b/crates/code-ranker-plugin-api/src/plugin.rs index b348e37b..f30c719d 100644 --- a/crates/code-ranker-plugin-api/src/plugin.rs +++ b/crates/code-ranker-plugin-api/src/plugin.rs @@ -17,7 +17,7 @@ use crate::graph::Graph; use crate::level::{AttributeSpec, Level}; use crate::metrics::MetricInputs; use crate::node::Node; -use crate::preset::Preset; +use crate::principle::Principle; use crate::report::ReportOverride; use anyhow::Result; use std::collections::BTreeMap; @@ -114,11 +114,11 @@ pub trait LanguagePlugin: Sync { Vec::new() } - /// The Prompt-Generator presets for this language. A plugin builds them from + /// The Prompt-Generator principles for this language. A plugin builds them from /// its own config (the common catalog in `defaults.toml` merged with the /// language's `<lang>.toml`, with each `doc_url` resolved). Default: none (a - /// plugin that ships no presets). - fn presets(&self, _input: &PluginInput) -> Vec<Preset> { + /// plugin that ships no principles). + fn principles(&self, _input: &PluginInput) -> Vec<Principle> { Vec::new() } @@ -172,7 +172,7 @@ mod tests { use crate::graph::Graph; /// A minimal plugin that implements only the required methods, so the trait's - /// default hooks (`versions` / `roots` / `presets` / `metric_specs` / + /// default hooks (`versions` / `roots` / `principles` / `metric_specs` / /// `thresholds` / `metrics`) are exercised as-is. struct Dummy; impl LanguagePlugin for Dummy { @@ -221,8 +221,8 @@ mod tests { // config defaults to an empty table (a stub with no config file). assert!(p.config().is_empty(), "default: empty config table"); - // presets defaults to none; metric_specs defaults to pass-through. - assert!(p.presets(&input).is_empty()); + // principles defaults to none; metric_specs defaults to pass-through. + assert!(p.principles(&input).is_empty()); let specs: BTreeMap<String, AttributeSpec> = BTreeMap::new(); assert!(p.metric_specs(specs).is_empty()); diff --git a/crates/code-ranker-plugin-api/src/preset.rs b/crates/code-ranker-plugin-api/src/principle.rs similarity index 72% rename from crates/code-ranker-plugin-api/src/preset.rs rename to crates/code-ranker-plugin-api/src/principle.rs index ce914f46..f1bf39a8 100644 --- a/crates/code-ranker-plugin-api/src/preset.rs +++ b/crates/code-ranker-plugin-api/src/principle.rs @@ -1,22 +1,22 @@ -//! The Prompt-Generator [`Preset`] DTO. +//! The Prompt-Generator [`Principle`] DTO. //! -//! A `Preset` is **prompt-generator domain data**, not part of the parser +//! A `Principle` is **prompt-generator domain data**, not part of the parser //! contract: a plugin *produces* its set (via -//! [`LanguagePlugin::presets`](crate::plugin::LanguagePlugin::presets)), but every +//! [`LanguagePlugin::principles`](crate::plugin::LanguagePlugin::principles)), but every //! other consumer — the report snapshot, the `recommend` console/prompt views — -//! only *reads* presets and never parses anything. The type therefore lives here, +//! only *reads* principles and never parses anything. The type therefore lives here, //! away from [`plugin`](crate::plugin), so those reporting consumers do not couple //! to the parsing contract just to name this struct. use serde::{Deserialize, Serialize}; /// The language-neutral **prompt scaffolding** the Prompt-Generator wraps a -/// [`Preset`] in — the framing prose around a principle (intro, the doc-read +/// [`Principle`] in — the framing prose around a principle (intro, the doc-read /// note, the task protocol, the focus line, and the dependency-cycle note). /// **Data, not code**: it lives in the metric catalog (`builtin.toml [prompt]`) /// and is carried in the snapshot, so the CLI's `prompt` format and the HTML /// viewer's Prompt Generator render the same text from one source. `{id}` in a -/// `task` line is substituted with the active preset id at render time. +/// `task` line is substituted with the active principle id at render time. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct PromptTemplate { /// One-line intent shown under the principle title. @@ -36,12 +36,12 @@ pub struct PromptTemplate { pub cycle_note: String, } -/// A Prompt-Generator preset (a refactoring principle): a ready-to-paste AI +/// A Prompt-Generator principle (a refactoring principle): a ready-to-paste AI /// instruction plus how the UI seeds the node selection for it. Each plugin -/// builds its own set from config via [`LanguagePlugin::presets`](crate::plugin::LanguagePlugin::presets) -/// (the common catalog plus any language-specific presets). +/// builds its own set from config via [`LanguagePlugin::principles`](crate::plugin::LanguagePlugin::principles) +/// (the common catalog plus any language-specific principles). #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Preset { +pub struct Principle { /// Stable id / short code shown on the button (e.g. `"ADP"`). pub id: String, /// Button label (usually the id). @@ -56,7 +56,7 @@ pub struct Preset { /// The metric the recommended-node list sorts by (an attribute key, or the /// pseudo-metric `"cycle"`). pub sort_metric: String, - /// Which connection sets the preset pre-selects: any of `"in"`/`"out"`/`"common"`. + /// Which connection sets the principle pre-selects: any of `"in"`/`"out"`/`"common"`. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub connections: Vec<String>, } diff --git a/crates/code-ranker-plugin-api/src/toml_merge.rs b/crates/code-ranker-plugin-api/src/toml_merge.rs index 48f8b038..b3719107 100644 --- a/crates/code-ranker-plugin-api/src/toml_merge.rs +++ b/crates/code-ranker-plugin-api/src/toml_merge.rs @@ -7,7 +7,7 @@ //! //! For each key of `overlay` applied onto `base`: //! - **table vs table** → recurse (per-key deep merge). -//! - **`[[presets]]` array of tables** → merge **by `id`**: an overlay preset with +//! - **`[[principles]]` array of tables** → merge **by `id`**: an overlay principle with //! an `id` already present in the base replaces that entry in place; a new `id` //! is appended. //! - **array patched by an op-table** (`{add,remove,replace,clear,prepend,…}`) → @@ -33,9 +33,9 @@ pub fn deep_merge(mut base: Table, overlay: Table) -> Table { base.insert(key, other); } }, - Some(Value::Array(ba)) if key == "presets" => { + Some(Value::Array(ba)) if key == "principles" => { if let Value::Array(oa) = ov { - base.insert(key, Value::Array(merge_presets(ba, oa))); + base.insert(key, Value::Array(merge_principles(ba, oa))); } else { base.insert(key, ov); } @@ -60,13 +60,13 @@ pub fn deep_merge(mut base: Table, overlay: Table) -> Table { base } -/// Merge two `[[presets]]` arrays by the `id` field: an overlay preset whose +/// Merge two `[[principles]]` arrays by the `id` field: an overlay principle whose /// `id` matches a base entry replaces it in place; a new `id` is appended. /// Entries without a string `id` are appended verbatim. -pub fn merge_presets(mut base: Vec<Value>, overlay: Vec<Value>) -> Vec<Value> { +pub fn merge_principles(mut base: Vec<Value>, overlay: Vec<Value>) -> Vec<Value> { for ov in overlay { - let ov_id = preset_id(&ov); - match ov_id.and_then(|id| base.iter().position(|b| preset_id(b) == Some(id))) { + let ov_id = principle_id(&ov); + match ov_id.and_then(|id| base.iter().position(|b| principle_id(b) == Some(id))) { Some(pos) => base[pos] = ov, None => base.push(ov), } @@ -74,6 +74,6 @@ pub fn merge_presets(mut base: Vec<Value>, overlay: Vec<Value>) -> Vec<Value> { base } -fn preset_id(v: &Value) -> Option<&str> { +fn principle_id(v: &Value) -> Option<&str> { v.as_table()?.get("id")?.as_str() } diff --git a/crates/code-ranker-plugins/Cargo.toml b/crates/code-ranker-plugins/Cargo.toml index 66cc690d..6d7a4de3 100644 --- a/crates/code-ranker-plugins/Cargo.toml +++ b/crates/code-ranker-plugins/Cargo.toml @@ -19,7 +19,7 @@ cargo_metadata = { workspace = true } syn = { workspace = true } proc-macro2 = { workspace = true } which = { workspace = true } -# Node-kind tables + presets/thresholds/specs for each language are data +# Node-kind tables + principles/thresholds/specs for each language are data # (`<lang>.toml` inheriting `defaults.toml`); parsed via serde + toml. serde = { workspace = true } toml = { workspace = true } diff --git a/crates/code-ranker-plugins/src/config/mod.rs b/crates/code-ranker-plugins/src/config/mod.rs index a7072fde..826b405a 100644 --- a/crates/code-ranker-plugins/src/config/mod.rs +++ b/crates/code-ranker-plugins/src/config/mod.rs @@ -3,7 +3,7 @@ //! Every language ships a `<lang>.toml` that **inherits** the common //! `defaults.toml` (see [`DEFAULTS`]). [`load`] deep-merges the two into one //! [`toml::Table`] from which a plugin drives its `levels()` spec overrides, -//! `presets()` and the metric-engine node-kind tables — so the +//! `principles()` and the metric-engine node-kind tables — so the //! per-language Rust stays thin (wiring only) and everything that *can* be data //! lives in TOML. //! @@ -15,7 +15,7 @@ //! - [`parse`] — TOML parsing + the `defaults.toml` ⊕ `<lang>.toml` deep-merge. //! - [`views`] — level-descriptor views (`edge_kinds` / `node_kinds` / //! `node_attributes` / `edge_attributes`, `edge_kind_id` / `attr_key`). -//! - [`specs`] — preset catalog, `[specs]` description overrides. +//! - [`specs`] — principle catalog, `[specs]` description overrides. //! - [`lookup`] — generic data-list lookups (`units` / `string_list` / //! `string_table`). @@ -27,7 +27,8 @@ mod views; pub use lookup::{IgnoreCfg, string_list, string_table, units}; pub use parse::{DEFAULTS, load, load_chain}; pub use specs::{ - PresetCfg, SpecOverride, apply_spec_overrides, presets, resolved_presets, spec_overrides, + PrincipleCfg, SpecOverride, apply_spec_overrides, principles, resolved_principles, + spec_overrides, }; pub use views::{attr_key, edge_attributes, edge_kind_id, edge_kinds, node_attributes, node_kinds}; diff --git a/crates/code-ranker-plugins/src/config/specs.rs b/crates/code-ranker-plugins/src/config/specs.rs index d2aeac24..c7f8c0e1 100644 --- a/crates/code-ranker-plugins/src/config/specs.rs +++ b/crates/code-ranker-plugins/src/config/specs.rs @@ -1,17 +1,17 @@ -//! Preset catalog and the `[specs.<key>]` description overrides a plugin applies +//! Principle catalog and the `[specs.<key>]` description overrides a plugin applies //! over the central `builtin.toml` attribute specs. -use code_ranker_plugin_api::Preset; +use code_ranker_plugin_api::Principle; use code_ranker_plugin_api::level::AttributeSpec; use serde::Deserialize; use std::collections::{BTreeMap, HashSet}; use toml::{Table, Value}; -/// One `[[presets]]` entry as read from config. Mirrors the data shape of the -/// CLI's generic preset catalog; the plugin turns it into a -/// `code_ranker_plugin_api::Preset`, deriving `doc_url` from its `id`. +/// One `[[principles]]` entry as read from config. Mirrors the data shape of the +/// CLI's generic principle catalog; the plugin turns it into a +/// `code_ranker_plugin_api::Principle`, deriving `doc_url` from its `id`. #[derive(Debug, Clone, Deserialize)] -pub struct PresetCfg { +pub struct PrincipleCfg { pub id: String, pub title: String, pub sort_metric: String, @@ -29,11 +29,11 @@ pub struct SpecOverride { pub description: Option<String>, } -/// Read the `[[presets]]` array from a merged config (empty if absent). -pub fn presets(cfg: &Table) -> Vec<PresetCfg> { - cfg.get("presets") +/// Read the `[[principles]]` array from a merged config (empty if absent). +pub fn principles(cfg: &Table) -> Vec<PrincipleCfg> { + cfg.get("principles") .cloned() - .map(|v| v.try_into().expect("[[presets]] shape")) + .map(|v| v.try_into().expect("[[principles]] shape")) .unwrap_or_default() } @@ -78,8 +78,8 @@ fn doc_overrides(cfg: &Table) -> DocOverrides { } } -/// Build the fully-resolved [`Preset`] list from a merged config: the common -/// catalog (from `defaults.toml`) plus any language-specific presets, in that +/// Build the fully-resolved [`Principle`] list from a merged config: the common +/// catalog (from `defaults.toml`) plus any language-specific principles, in that /// order (the merge-by-`id` already yields it). `label` is the `id`. /// /// `doc_url` inherits from a shared `base/` corpus the same way config inherits @@ -89,13 +89,13 @@ fn doc_overrides(cfg: &Table) -> DocOverrides { /// link at `base/`, and a full-corpus language (`doc_overrides = "*"`) points /// every link at its own folder. `doc_base` (the host/repo prefix, common) lives /// in `defaults.toml`; if it is absent the `doc_url` is left `None`. -pub fn resolved_presets(cfg: &Table) -> Vec<Preset> { +pub fn resolved_principles(cfg: &Table) -> Vec<Principle> { let base = string_field(cfg, "doc_base"); let lang = string_field(cfg, "doc_lang"); let overrides = doc_overrides(cfg); - presets(cfg) + principles(cfg) .into_iter() - .map(|p| Preset { + .map(|p| Principle { doc_url: base.map(|b| { // Own folder for an overridden id (needs a `doc_lang`); `base/` // otherwise — the shared fallback corpus. diff --git a/crates/code-ranker-plugins/src/defaults.toml b/crates/code-ranker-plugins/src/defaults.toml index 3f7d3d72..32288b32 100644 --- a/crates/code-ranker-plugins/src/defaults.toml +++ b/crates/code-ranker-plugins/src/defaults.toml @@ -2,8 +2,8 @@ # # `code-ranker-plugins/src/config.rs` parses this file and a language's # `<lang>.toml`, then DEEP-MERGES them: per section, per key the language value -# overrides the base; the `[[presets]]` array is merged by `id` (a language -# preset replaces a same-`id` base preset, a new one appends). See that module's +# overrides the base; the `[[principles]]` array is merged by `id` (a language +# principle replaces a same-`id` base principle, a new one appends). See that module's # docs for the exact rules. # # This base holds the language-neutral defaults. A language inherits everything @@ -12,13 +12,13 @@ # doc_base / doc_lang the principle-doc URL pieces. `doc_base` (the host/repo # prefix) is COMMON and lives here; each `<lang>.toml` # supplies `doc_lang` (its principle-corpus language, e.g. -# `rust`, `python`, `typescript`). A preset's `doc_url` is +# `rust`, `python`, `typescript`). A principle's `doc_url` is # resolved as `{doc_base}/{doc_lang}/{id}.md` (the `id` is # the filename — there is no separate `slug`). -# [[presets]] design-principle recommendation presets (id, title, +# [[principles]] design-principle recommendation principles (id, title, # sort_metric, connections, prompt). The COMMON, # language-neutral catalog lives here ONCE; every language -# inherits it. A language may append its own presets (or +# inherits it. A language may append its own principles (or # override one by `id`) in its `<lang>.toml`. Merged by id. # [specs.<key>] per-language tweaks to a metric's display (e.g. # description), applied over the central builtin specs. @@ -27,7 +27,7 @@ # (these are inherently grammar-specific, so the base # leaves them to each language). -# The principle-doc URL prefix shared by every language. A preset's `doc_url` +# The principle-doc URL prefix shared by every language. A principle's `doc_url` # resolves to `{doc_base}/{doc_lang}/{id}.md` for the ids a language overrides # (its `doc_overrides`), and to `{doc_base}/base/{id}.md` otherwise — `base/` is # the shared fallback corpus a language inherits when it has no own doc, mirroring @@ -137,14 +137,14 @@ value_type = "bool" label = "External" # ────────────────────────────────────────────────────────────────────────────── -# The COMMON, language-neutral metric-lens preset catalog (the recommendation +# The COMMON, language-neutral metric-lens principle catalog (the recommendation # lenses). Defined ONCE here; every language inherits it. A `<lang>.toml` may -# append language-specific presets or override an entry by `id`. The long prompt +# append language-specific principles or override an entry by `id`. The long prompt # bodies are TOML multiline strings (the trailing `\` keeps them newline-free at # the end, matching the rendered output exactly). # ────────────────────────────────────────────────────────────────────────────── -[[presets]] +[[principles]] id = "CPX" title = "CPX — Reduce Complexity" sort_metric = "cognitive" @@ -156,7 +156,7 @@ extracting repeated patterns into shared helpers, flattening deeply nested control flow, and breaking large functions into focused helpers.\ """ -[[presets]] +[[principles]] id = "ADP" title = "ADP — Acyclic Dependencies Principle" sort_metric = "cycle" @@ -176,7 +176,7 @@ When splitting a module to break a cycle, the new structure should: - Not introduce new dependency cycles\ """ -[[presets]] +[[principles]] id = "SRP" title = "SRP — Single Responsibility Principle" sort_metric = "sloc" @@ -190,7 +190,7 @@ Propose how to split responsibilities so each module changes for only one reason and specify the new module boundaries.\ """ -[[presets]] +[[principles]] id = "OCP" title = "OCP — Open/Closed Principle" sort_metric = "cyclomatic" @@ -205,7 +205,7 @@ mechanism (polymorphism, strategy, plug-in registration) so new cases can be add without modifying these modules.\ """ -[[presets]] +[[principles]] id = "LSP" title = "LSP — Liskov Substitution Principle" sort_metric = "hk" @@ -221,7 +221,7 @@ replace any other implementation of the same interface without breaking callers. Flag violations and propose fixes.\ """ -[[presets]] +[[principles]] id = "ISP" title = "ISP — Interface Segregation Principle" sort_metric = "items" @@ -235,7 +235,7 @@ Propose how to split them into narrower interfaces so each consumer depends only what it actually uses.\ """ -[[presets]] +[[principles]] id = "DIP" title = "DIP — Dependency Inversion Principle" sort_metric = "fan_out" @@ -249,7 +249,7 @@ concrete low-level type. Propose an abstraction (interface) to invert each such dependency, and specify where the concrete implementation should be wired in.\ """ -[[presets]] +[[principles]] id = "DRY" title = "DRY — Don't Repeat Yourself" sort_metric = "sloc" @@ -263,7 +263,7 @@ below. For each duplication, propose a canonical location and the refactoring needed to consolidate it.\ """ -[[presets]] +[[principles]] id = "KISS" title = "KISS — Keep It Simple" sort_metric = "cognitive" @@ -276,7 +276,7 @@ Identify over-engineered or needlessly complex constructs in the modules below. For each, describe the simpler alternative and estimate the risk of simplifying.\ """ -[[presets]] +[[principles]] id = "LoD" title = "Law of Demeter — Principle of Least Knowledge" sort_metric = "fan_out" @@ -291,7 +291,7 @@ violate LoD. For each, propose a narrow accessor or a facade that exposes only what the caller needs, reducing coupling.\ """ -[[presets]] +[[principles]] id = "MISU" title = "MISU — Make Invalid States Unrepresentable" sort_metric = "cyclomatic" @@ -306,7 +306,7 @@ states are representable at runtime. For each, propose a type-level encoding by construction.\ """ -[[presets]] +[[principles]] id = "CoI" title = "CoI — Composition Over Inheritance" sort_metric = "items" @@ -320,7 +320,7 @@ decompose them into smaller composable parts, and show how consumers would assem the behaviour they need.\ """ -[[presets]] +[[principles]] id = "YAGNI" title = "YAGNI — You Aren't Gonna Need It" sort_metric = "sloc" diff --git a/crates/code-ranker-plugins/src/languages/README.md b/crates/code-ranker-plugins/src/languages/README.md index 879a1af2..9a91c1b7 100644 --- a/crates/code-ranker-plugins/src/languages/README.md +++ b/crates/code-ranker-plugins/src/languages/README.md @@ -11,7 +11,7 @@ others. ``` src/ lib.rs ← declares `languages` + re-exports the Plugin structs at the crate root - config.rs ← the config loader (defaults.toml merge + preset resolution) + config.rs ← the config loader (defaults.toml merge + principle resolution) defaults.toml ← the COMMON base config every language inherits engine/ ← the GENERIC tree-sitter metric engine (shared by all langs) mod.rs ← compute / compute_functions driver + measure() @@ -97,8 +97,8 @@ overrides only what differs**. A language config carries: (operators/operands, branches, exits, statements, comments, spaces, …). The walk logic stays in `engine/`; *which kinds it counts* is data. (Reference: the `[roles]` / `[halstead]` / `[loc]` sections of `languages/rust/config.toml`.) -- **metric presets** — the recommendation lenses (e.g. `HK`), including the long - prompt text (use TOML multiline strings). +- **principles** — the recommendation `[[principles]]` (e.g. `ADP`), including the + long prompt text (use TOML multiline strings). - **spec overrides** — language-specific tweaks to a metric's display (e.g. the Rust `tloc`/`sloc` description mentioning `#[cfg(test)]`). - **thresholds** — language-calibrated `info`/`warning` limits per metric. @@ -231,7 +231,7 @@ display spec) belongs. When you add a metric: 1. `src/languages/foo/mod.rs` — `pub struct FooPlugin;` + thin `impl LanguagePlugin`. 2. `src/languages/foo/config.toml` — inherit `defaults.toml`; add node-kind tables, - presets, thresholds, spec overrides (only the diffs). + principles, thresholds, spec overrides (only the diffs). 3. Imperative submodules — the AST walk / structure builder (or reuse a shared module like `languages/ecmascript/`). 4. `src/languages/foo/tests/<source>.rs` for each source file; diff --git a/crates/code-ranker-plugins/src/languages/c/mod.rs b/crates/code-ranker-plugins/src/languages/c/mod.rs index e41eeec2..5c5fa47d 100644 --- a/crates/code-ranker-plugins/src/languages/c/mod.rs +++ b/crates/code-ranker-plugins/src/languages/c/mod.rs @@ -5,7 +5,7 @@ use anyhow::Result; use code_ranker_plugin_api::{ - Preset, default_cycle_kinds, default_node_kinds, + Principle, default_cycle_kinds, default_node_kinds, graph::Graph, level::{AttributeSpec, Level}, metrics::MetricInputs, @@ -95,8 +95,8 @@ impl LanguagePlugin for CPlugin { function_nodes(graph) } - fn presets(&self, _input: &PluginInput) -> Vec<Preset> { - crate::config::resolved_presets(&CONFIG) + fn principles(&self, _input: &PluginInput) -> Vec<Principle> { + crate::config::resolved_principles(&CONFIG) } fn report_overrides(&self) -> code_ranker_plugin_api::report::ReportOverride { 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 e6052a4e..0b4849dc 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 @@ -126,7 +126,7 @@ "group": "complexity", "label": "Cognitive", "name": "Cognitive complexity", - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/Cognitive.md", + "remediation": "Run `code-ranker report --doc Cognitive` and follow its instructions.", "short": "Cognitive", "value_type": "int" }, @@ -139,7 +139,7 @@ "label": "Cyclomatic", "name": "Cyclomatic complexity", "omit_at": 1.0, - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/Cyclomatic.md", + "remediation": "Run `code-ranker report --doc Cyclomatic` and follow its instructions.", "short": "Cyclomatic", "value_type": "int" }, @@ -190,7 +190,7 @@ "group": "coupling", "label": "Fan-in", "name": "Fan-in", - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/Fan-in.md", + "remediation": "Run `code-ranker report --doc Fan-in` and follow its instructions.", "short": "Fan-in", "value_type": "int" }, @@ -199,7 +199,7 @@ "group": "coupling", "label": "Fan-out", "name": "Fan-out", - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/Fan-out.md", + "remediation": "Run `code-ranker report --doc Fan-out` and follow its instructions.", "short": "Fan-out", "value_type": "int" }, @@ -220,7 +220,7 @@ "group": "coupling", "label": "HK", "name": "Henry–Kafura", - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/HK.md", + "remediation": "Run `code-ranker report --doc HK` and follow its instructions.", "short": "HK", "value_type": "float" }, @@ -604,7 +604,7 @@ } }, "plugin": "c", - "presets": [ + "principles": [ { "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/CPX.md", "id": "CPX", diff --git a/crates/code-ranker-plugins/src/languages/cpp/mod.rs b/crates/code-ranker-plugins/src/languages/cpp/mod.rs index 88a2b64d..564e26ca 100644 --- a/crates/code-ranker-plugins/src/languages/cpp/mod.rs +++ b/crates/code-ranker-plugins/src/languages/cpp/mod.rs @@ -5,7 +5,7 @@ use anyhow::Result; use code_ranker_plugin_api::{ - Preset, default_cycle_kinds, default_node_kinds, + Principle, default_cycle_kinds, default_node_kinds, graph::Graph, level::{AttributeSpec, Level}, metrics::MetricInputs, @@ -95,8 +95,8 @@ impl LanguagePlugin for CppPlugin { function_nodes(graph) } - fn presets(&self, _input: &PluginInput) -> Vec<Preset> { - crate::config::resolved_presets(&CONFIG) + fn principles(&self, _input: &PluginInput) -> Vec<Principle> { + crate::config::resolved_principles(&CONFIG) } fn report_overrides(&self) -> code_ranker_plugin_api::report::ReportOverride { 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 e6f996da..50ea355e 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 @@ -135,7 +135,7 @@ "group": "complexity", "label": "Cognitive", "name": "Cognitive complexity", - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/Cognitive.md", + "remediation": "Run `code-ranker report --doc Cognitive` and follow its instructions.", "short": "Cognitive", "value_type": "int" }, @@ -148,7 +148,7 @@ "label": "Cyclomatic", "name": "Cyclomatic complexity", "omit_at": 1.0, - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/Cyclomatic.md", + "remediation": "Run `code-ranker report --doc Cyclomatic` and follow its instructions.", "short": "Cyclomatic", "value_type": "int" }, @@ -199,7 +199,7 @@ "group": "coupling", "label": "Fan-in", "name": "Fan-in", - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/Fan-in.md", + "remediation": "Run `code-ranker report --doc Fan-in` and follow its instructions.", "short": "Fan-in", "value_type": "int" }, @@ -208,7 +208,7 @@ "group": "coupling", "label": "Fan-out", "name": "Fan-out", - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/Fan-out.md", + "remediation": "Run `code-ranker report --doc Fan-out` and follow its instructions.", "short": "Fan-out", "value_type": "int" }, @@ -229,7 +229,7 @@ "group": "coupling", "label": "HK", "name": "Henry–Kafura", - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/HK.md", + "remediation": "Run `code-ranker report --doc HK` and follow its instructions.", "short": "HK", "value_type": "float" }, @@ -614,7 +614,7 @@ } }, "plugin": "cpp", - "presets": [ + "principles": [ { "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/CPX.md", "id": "CPX", diff --git a/crates/code-ranker-plugins/src/languages/csharp/mod.rs b/crates/code-ranker-plugins/src/languages/csharp/mod.rs index 30aa8b4e..610283b9 100644 --- a/crates/code-ranker-plugins/src/languages/csharp/mod.rs +++ b/crates/code-ranker-plugins/src/languages/csharp/mod.rs @@ -5,7 +5,7 @@ use anyhow::Result; use code_ranker_plugin_api::{ - Preset, default_cycle_kinds, default_node_kinds, + Principle, default_cycle_kinds, default_node_kinds, graph::Graph, level::{AttributeSpec, Level}, metrics::MetricInputs, @@ -85,8 +85,8 @@ impl LanguagePlugin for CsharpPlugin { function_nodes(graph) } - fn presets(&self, _input: &PluginInput) -> Vec<Preset> { - crate::config::resolved_presets(&CONFIG) + fn principles(&self, _input: &PluginInput) -> Vec<Principle> { + crate::config::resolved_principles(&CONFIG) } fn report_overrides(&self) -> code_ranker_plugin_api::report::ReportOverride { 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 585d0e42..33ca4e82 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 @@ -123,7 +123,7 @@ "group": "complexity", "label": "Cognitive", "name": "Cognitive complexity", - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/Cognitive.md", + "remediation": "Run `code-ranker report --doc Cognitive` and follow its instructions.", "short": "Cognitive", "value_type": "int" }, @@ -136,7 +136,7 @@ "label": "Cyclomatic", "name": "Cyclomatic complexity", "omit_at": 1.0, - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/Cyclomatic.md", + "remediation": "Run `code-ranker report --doc Cyclomatic` and follow its instructions.", "short": "Cyclomatic", "value_type": "int" }, @@ -187,7 +187,7 @@ "group": "coupling", "label": "Fan-in", "name": "Fan-in", - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/Fan-in.md", + "remediation": "Run `code-ranker report --doc Fan-in` and follow its instructions.", "short": "Fan-in", "value_type": "int" }, @@ -196,7 +196,7 @@ "group": "coupling", "label": "Fan-out", "name": "Fan-out", - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/Fan-out.md", + "remediation": "Run `code-ranker report --doc Fan-out` and follow its instructions.", "short": "Fan-out", "value_type": "int" }, @@ -217,7 +217,7 @@ "group": "coupling", "label": "HK", "name": "Henry–Kafura", - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/HK.md", + "remediation": "Run `code-ranker report --doc HK` and follow its instructions.", "short": "HK", "value_type": "float" }, @@ -550,7 +550,7 @@ } }, "plugin": "csharp", - "presets": [ + "principles": [ { "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/CPX.md", "id": "CPX", diff --git a/crates/code-ranker-plugins/src/languages/ecmascript/config.toml b/crates/code-ranker-plugins/src/languages/ecmascript/config.toml index 9a1903be..d5c7fff9 100644 --- a/crates/code-ranker-plugins/src/languages/ecmascript/config.toml +++ b/crates/code-ranker-plugins/src/languages/ecmascript/config.toml @@ -4,7 +4,7 @@ # (and the `else_if_via_else_clause` behavior flag) and reuse the generic engine # (`crate::engine`) via `ecmascript/dialect.rs`. The node-kind strings are # identical for both dialects; each is resolved against its own grammar. -# ECMAScript carries NO presets / thresholds / spec overrides — its only +# ECMAScript carries NO principles / thresholds / spec overrides — its only # language-specific data is the tree-sitter node-kind tables the engine keys on: # # [roles] / [halstead] / [loc] tree-sitter node-kind ROLE tables for the diff --git a/crates/code-ranker-plugins/src/languages/go/config.toml b/crates/code-ranker-plugins/src/languages/go/config.toml index 8e40970a..0790d320 100644 --- a/crates/code-ranker-plugins/src/languages/go/config.toml +++ b/crates/code-ranker-plugins/src/languages/go/config.toml @@ -2,7 +2,7 @@ # # Everything language-specific for Go that *can* be data lives here, so `mod.rs` # stays thin (wiring only) and the metric engine is parameterised by the node-kind -# tables below. Go adds NO presets/thresholds/spec-overrides of its own beyond the +# tables below. Go adds NO principles/thresholds/spec-overrides of its own beyond the # Halstead token descriptions; it inherits the common catalog from `defaults.toml`. # No own principle corpus yet: with no `doc_overrides`, every `doc_url` inherits diff --git a/crates/code-ranker-plugins/src/languages/go/mod.rs b/crates/code-ranker-plugins/src/languages/go/mod.rs index a2f2df16..4685ee51 100644 --- a/crates/code-ranker-plugins/src/languages/go/mod.rs +++ b/crates/code-ranker-plugins/src/languages/go/mod.rs @@ -6,7 +6,7 @@ use anyhow::Result; use code_ranker_plugin_api::{ - Preset, default_cycle_kinds, default_node_kinds, + Principle, default_cycle_kinds, default_node_kinds, graph::Graph, level::{AttributeSpec, Level, NodeKindSpec}, metrics::MetricInputs, @@ -91,8 +91,8 @@ impl LanguagePlugin for GoPlugin { function_nodes(graph) } - fn presets(&self, _input: &PluginInput) -> Vec<Preset> { - crate::config::resolved_presets(&CONFIG) + fn principles(&self, _input: &PluginInput) -> Vec<Principle> { + crate::config::resolved_principles(&CONFIG) } fn report_overrides(&self) -> code_ranker_plugin_api::report::ReportOverride { 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 5f7785a2..a5c2b932 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 @@ -123,7 +123,7 @@ "group": "complexity", "label": "Cognitive", "name": "Cognitive complexity", - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/Cognitive.md", + "remediation": "Run `code-ranker report --doc Cognitive` and follow its instructions.", "short": "Cognitive", "value_type": "int" }, @@ -136,7 +136,7 @@ "label": "Cyclomatic", "name": "Cyclomatic complexity", "omit_at": 1.0, - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/Cyclomatic.md", + "remediation": "Run `code-ranker report --doc Cyclomatic` and follow its instructions.", "short": "Cyclomatic", "value_type": "int" }, @@ -187,7 +187,7 @@ "group": "coupling", "label": "Fan-in", "name": "Fan-in", - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/Fan-in.md", + "remediation": "Run `code-ranker report --doc Fan-in` and follow its instructions.", "short": "Fan-in", "value_type": "int" }, @@ -196,7 +196,7 @@ "group": "coupling", "label": "Fan-out", "name": "Fan-out", - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/Fan-out.md", + "remediation": "Run `code-ranker report --doc Fan-out` and follow its instructions.", "short": "Fan-out", "value_type": "int" }, @@ -217,7 +217,7 @@ "group": "coupling", "label": "HK", "name": "Henry–Kafura", - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/HK.md", + "remediation": "Run `code-ranker report --doc HK` and follow its instructions.", "short": "HK", "value_type": "float" }, @@ -552,7 +552,7 @@ } }, "plugin": "go", - "presets": [ + "principles": [ { "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/base/CPX.md", "id": "CPX", diff --git a/crates/code-ranker-plugins/src/languages/javascript/config.toml b/crates/code-ranker-plugins/src/languages/javascript/config.toml index 59ac7796..1bdaa9e1 100644 --- a/crates/code-ranker-plugins/src/languages/javascript/config.toml +++ b/crates/code-ranker-plugins/src/languages/javascript/config.toml @@ -5,7 +5,7 @@ # same engine). This file therefore carries only what is JavaScript-specific to # the language-neutral config layer: its principle-corpus language for doc links. # -# JavaScript adds NO metric-lens presets of its own — it inherits the common +# JavaScript adds NO metric-lens principles of its own — it inherits the common # catalog from `defaults.toml`. Its principle docs share the TypeScript corpus # (matching the historical `principle_lang` mapping js → typescript), so # `doc_url` = `{doc_base}/typescript/<slug>.md`. diff --git a/crates/code-ranker-plugins/src/languages/javascript/mod.rs b/crates/code-ranker-plugins/src/languages/javascript/mod.rs index f42fcf32..5079bf8b 100644 --- a/crates/code-ranker-plugins/src/languages/javascript/mod.rs +++ b/crates/code-ranker-plugins/src/languages/javascript/mod.rs @@ -12,7 +12,7 @@ use crate::languages::ecmascript::{ }; use anyhow::Result; use code_ranker_plugin_api::{ - Preset, detect_with_marker, + Principle, detect_with_marker, graph::Graph, level::{AttributeSpec, Level}, metrics::MetricInputs, @@ -100,10 +100,10 @@ impl LanguagePlugin for JavascriptPlugin { }) } - fn presets(&self, _input: &PluginInput) -> Vec<Preset> { + fn principles(&self, _input: &PluginInput) -> Vec<Principle> { // The common catalog from `defaults.toml`, with `doc_url` resolved to // `{doc_base}/typescript/<slug>.md` (JS shares the TS principle corpus). - crate::config::resolved_presets(&CONFIG) + crate::config::resolved_principles(&CONFIG) } fn report_overrides(&self) -> code_ranker_plugin_api::report::ReportOverride { diff --git a/crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker-report.json b/crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker-report.json index c92d6ef7..f66cda60 100644 --- a/crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker-report.json +++ b/crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker-report.json @@ -36,12 +36,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": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/ADP.md" + "remediation": "Run `code-ranker report --doc 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": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/ADP.md" + "remediation": "Run `code-ranker report --doc ADP` and follow its instructions." } }, "cycles": [ @@ -198,7 +198,7 @@ "group": "complexity", "label": "Cognitive", "name": "Cognitive complexity", - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/Cognitive.md", + "remediation": "Run `code-ranker report --doc Cognitive` and follow its instructions.", "short": "Cognitive", "value_type": "int" }, @@ -218,7 +218,7 @@ "label": "Cyclomatic", "name": "Cyclomatic complexity", "omit_at": 1.0, - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/Cyclomatic.md", + "remediation": "Run `code-ranker report --doc Cyclomatic` and follow its instructions.", "short": "Cyclomatic", "value_type": "int" }, @@ -269,7 +269,7 @@ "group": "coupling", "label": "Fan-in", "name": "Fan-in", - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/Fan-in.md", + "remediation": "Run `code-ranker report --doc Fan-in` and follow its instructions.", "short": "Fan-in", "value_type": "int" }, @@ -278,7 +278,7 @@ "group": "coupling", "label": "Fan-out", "name": "Fan-out", - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/Fan-out.md", + "remediation": "Run `code-ranker report --doc Fan-out` and follow its instructions.", "short": "Fan-out", "value_type": "int" }, @@ -299,7 +299,7 @@ "group": "coupling", "label": "HK", "name": "Henry–Kafura", - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/HK.md", + "remediation": "Run `code-ranker report --doc HK` and follow its instructions.", "short": "HK", "value_type": "float" }, @@ -793,7 +793,7 @@ } }, "plugin": "javascript", - "presets": [ + "principles": [ { "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/typescript/CPX.md", "id": "CPX", diff --git a/crates/code-ranker-plugins/src/languages/markdown/mod.rs b/crates/code-ranker-plugins/src/languages/markdown/mod.rs index 7e963e7d..2a474174 100644 --- a/crates/code-ranker-plugins/src/languages/markdown/mod.rs +++ b/crates/code-ranker-plugins/src/languages/markdown/mod.rs @@ -7,7 +7,7 @@ use anyhow::Result; use code_ranker_plugin_api::{ - Preset, default_cycle_kinds, default_node_kinds, + Principle, default_cycle_kinds, default_node_kinds, graph::Graph, level::Level, plugin::{LanguagePlugin, PluginInput}, @@ -63,7 +63,7 @@ impl LanguagePlugin for MarkdownPlugin { // No `metrics` / `function_units`: Markdown emits only the structural `loc` // (set in `analyze`) plus the orchestrator-derived coupling over the links. - fn presets(&self, _input: &PluginInput) -> Vec<Preset> { + fn principles(&self, _input: &PluginInput) -> Vec<Principle> { // The common catalog is a set of code-refactoring lenses — not meaningful // for prose — so Markdown ships none. Vec::new() diff --git a/crates/code-ranker-plugins/src/languages/markdown/tests/mod_rs.rs b/crates/code-ranker-plugins/src/languages/markdown/tests/mod_rs.rs index 91c205c5..df872530 100644 --- a/crates/code-ranker-plugins/src/languages/markdown/tests/mod_rs.rs +++ b/crates/code-ranker-plugins/src/languages/markdown/tests/mod_rs.rs @@ -3,14 +3,14 @@ use super::*; #[test] -fn detects_by_md_presence_and_has_no_presets() { +fn detects_by_md_presence_and_has_no_principles() { let d = tempfile::tempdir().unwrap(); let p = MarkdownPlugin; assert!(!p.detect(d.path(), &PluginInput::default())); std::fs::write(d.path().join("README.md"), "# Hi\n").unwrap(); assert!(p.detect(d.path(), &PluginInput::default())); assert_eq!(p.name(), "markdown"); - assert!(p.presets(&PluginInput::default()).is_empty()); + assert!(p.principles(&PluginInput::default()).is_empty()); } #[test] diff --git a/crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker-report.json b/crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker-report.json index ba08df8a..3d18d8e8 100644 --- a/crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker-report.json +++ b/crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker-report.json @@ -20,7 +20,7 @@ "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": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/ADP.md" + "remediation": "Run `code-ranker report --doc ADP` and follow its instructions." } }, "cycles": [ @@ -98,7 +98,7 @@ "group": "coupling", "label": "Fan-in", "name": "Fan-in", - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/Fan-in.md", + "remediation": "Run `code-ranker report --doc Fan-in` and follow its instructions.", "short": "Fan-in", "value_type": "int" }, @@ -107,7 +107,7 @@ "group": "coupling", "label": "Fan-out", "name": "Fan-out", - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/Fan-out.md", + "remediation": "Run `code-ranker report --doc Fan-out` and follow its instructions.", "short": "Fan-out", "value_type": "int" }, diff --git a/crates/code-ranker-plugins/src/languages/python/config.toml b/crates/code-ranker-plugins/src/languages/python/config.toml index ea00e5be..6a9a4eca 100644 --- a/crates/code-ranker-plugins/src/languages/python/config.toml +++ b/crates/code-ranker-plugins/src/languages/python/config.toml @@ -2,7 +2,7 @@ # # Everything language-specific for Python that *can* be data lives here, so # `mod.rs` and `python_ts.rs` stay thin (wiring / walk logic only). Python adds -# NO metric-lens presets of its own (it inherits the common catalog from +# NO metric-lens principles of its own (it inherits the common catalog from # `defaults.toml`), no language-calibrated thresholds, and no spec overrides — # its only language-specific data is `doc_lang` plus the tree-sitter node-kind # tables the metric engine keys on: diff --git a/crates/code-ranker-plugins/src/languages/python/mod.rs b/crates/code-ranker-plugins/src/languages/python/mod.rs index 0bc63483..752d18fd 100644 --- a/crates/code-ranker-plugins/src/languages/python/mod.rs +++ b/crates/code-ranker-plugins/src/languages/python/mod.rs @@ -1,6 +1,6 @@ use anyhow::Result; use code_ranker_plugin_api::{ - Preset, default_cycle_kinds, default_node_kinds, + Principle, default_cycle_kinds, default_node_kinds, graph::Graph, level::{AttributeSpec, Level, NodeKindSpec}, metrics::MetricInputs, @@ -15,7 +15,7 @@ mod dialect; mod structure; /// The Python config: `python.toml` deep-merged over the shared `defaults.toml`, -/// used to build the preset list (the common catalog + Python's `doc_lang`). +/// used to build the principle list (the common catalog + Python's `doc_lang`). static CONFIG: LazyLock<toml::Table> = LazyLock::new(|| crate::config::load(include_str!("config.toml"))); @@ -98,10 +98,10 @@ impl LanguagePlugin for PythonPlugin { function_nodes(graph) } - fn presets(&self, _input: &PluginInput) -> Vec<Preset> { + fn principles(&self, _input: &PluginInput) -> Vec<Principle> { // The common catalog from `defaults.toml`, with `doc_url` resolved to - // `{doc_base}/python/<slug>.md` (Python adds no presets of its own). - crate::config::resolved_presets(&CONFIG) + // `{doc_base}/python/<slug>.md` (Python adds no principles of its own). + crate::config::resolved_principles(&CONFIG) } fn report_overrides(&self) -> code_ranker_plugin_api::report::ReportOverride { 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 eef17d13..dcfab8a5 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 @@ -36,12 +36,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": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/ADP.md" + "remediation": "Run `code-ranker report --doc 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": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/ADP.md" + "remediation": "Run `code-ranker report --doc ADP` and follow its instructions." } }, "cycles": [ @@ -252,7 +252,7 @@ "group": "complexity", "label": "Cognitive", "name": "Cognitive complexity", - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/Cognitive.md", + "remediation": "Run `code-ranker report --doc Cognitive` and follow its instructions.", "short": "Cognitive", "value_type": "int" }, @@ -272,7 +272,7 @@ "label": "Cyclomatic", "name": "Cyclomatic complexity", "omit_at": 1.0, - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/Cyclomatic.md", + "remediation": "Run `code-ranker report --doc Cyclomatic` and follow its instructions.", "short": "Cyclomatic", "value_type": "int" }, @@ -323,7 +323,7 @@ "group": "coupling", "label": "Fan-in", "name": "Fan-in", - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/Fan-in.md", + "remediation": "Run `code-ranker report --doc Fan-in` and follow its instructions.", "short": "Fan-in", "value_type": "int" }, @@ -332,7 +332,7 @@ "group": "coupling", "label": "Fan-out", "name": "Fan-out", - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/Fan-out.md", + "remediation": "Run `code-ranker report --doc Fan-out` and follow its instructions.", "short": "Fan-out", "value_type": "int" }, @@ -353,7 +353,7 @@ "group": "coupling", "label": "HK", "name": "Henry–Kafura", - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/HK.md", + "remediation": "Run `code-ranker report --doc HK` and follow its instructions.", "short": "HK", "value_type": "float" }, @@ -902,7 +902,7 @@ } }, "plugin": "python", - "presets": [ + "principles": [ { "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/python/CPX.md", "id": "CPX", diff --git a/crates/code-ranker-plugins/src/languages/rust/cfg.rs b/crates/code-ranker-plugins/src/languages/rust/cfg.rs index 77b37ad4..98a2418f 100644 --- a/crates/code-ranker-plugins/src/languages/rust/cfg.rs +++ b/crates/code-ranker-plugins/src/languages/rust/cfg.rs @@ -1,5 +1,5 @@ //! The Rust plugin's merged config, in a leaf module so both `mod.rs` (which -//! builds `levels()` / `thresholds()` / `presets()` from it) and `collapse.rs` +//! builds `levels()` / `thresholds()` / `principles()` from it) and `collapse.rs` //! (which reads the edge-kind identifiers from it) can depend on it *down* — //! referencing `super::CONFIG` from `collapse.rs` would otherwise close a //! `mod.rs ↔ collapse.rs` cycle (mod.rs already owns `collapse`). Same rationale @@ -9,7 +9,7 @@ use std::collections::BTreeMap; use std::sync::LazyLock; /// The Rust config: `config.toml` deep-merged over the shared `defaults.toml`. -/// Drives `levels()` (edge/node-kind vocab) / `thresholds()` / `presets()` / +/// Drives `levels()` (edge/node-kind vocab) / `thresholds()` / `principles()` / /// `metric_specs()` so `mod.rs` stays thin — the language-specific data lives in /// `config.toml`, not in code. pub(crate) static CONFIG: LazyLock<toml::Table> = diff --git a/crates/code-ranker-plugins/src/languages/rust/config.toml b/crates/code-ranker-plugins/src/languages/rust/config.toml index 479b7ab4..d5261477 100644 --- a/crates/code-ranker-plugins/src/languages/rust/config.toml +++ b/crates/code-ranker-plugins/src/languages/rust/config.toml @@ -6,7 +6,7 @@ # [kinds] / [halstead] / [loc] tree-sitter node-kind tables for the metric # engine (`rust_ts.rs`); the walk logic stays in # Rust, *which* kinds it counts is data. -# [[presets]] Rust-only metric-lens presets, appended to the +# [[principles]] Rust-only metric-lens principles, appended to the # CLI's generic catalog (merged by `id`). # [thresholds.<key>] Rust-calibrated info/warning limits. # [specs.<key>] Rust tweaks to a metric's display, applied over diff --git a/crates/code-ranker-plugins/src/languages/rust/mod.rs b/crates/code-ranker-plugins/src/languages/rust/mod.rs index 8a3d0590..eb93327f 100644 --- a/crates/code-ranker-plugins/src/languages/rust/mod.rs +++ b/crates/code-ranker-plugins/src/languages/rust/mod.rs @@ -1,6 +1,6 @@ use anyhow::Result; use code_ranker_plugin_api::{ - Preset, default_cycle_kinds, default_node_kinds, + Principle, default_cycle_kinds, default_node_kinds, graph::Graph, level::{AttributeSpec, EdgeKindSpec, Grouping, Level, NodeKindSpec}, metrics::MetricInputs, @@ -107,11 +107,11 @@ impl LanguagePlugin for RustPlugin { ] } - fn presets(&self, _input: &PluginInput) -> Vec<Preset> { + fn principles(&self, _input: &PluginInput) -> Vec<Principle> { // The common catalog (from `defaults.toml`) plus the Rust-only metric - // lenses (`[[presets]]` in `rust.toml`), with each `doc_url` resolved to + // lenses (`[[principles]]` in `rust.toml`), with each `doc_url` resolved to // `{doc_base}/rust/<slug>.md`. All data-driven via the shared loader. - crate::config::resolved_presets(&CONFIG) + crate::config::resolved_principles(&CONFIG) } fn report_overrides(&self) -> code_ranker_plugin_api::report::ReportOverride { 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 ed717eca..0b0d285e 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 @@ -36,12 +36,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": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/ADP.md" + "remediation": "Run `code-ranker report --doc 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": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/ADP.md" + "remediation": "Run `code-ranker report --doc ADP` and follow its instructions." } }, "cycles": [ @@ -421,7 +421,7 @@ "group": "complexity", "label": "Cognitive", "name": "Cognitive complexity", - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/Cognitive.md", + "remediation": "Run `code-ranker report --doc Cognitive` and follow its instructions.", "short": "Cognitive", "value_type": "int" }, @@ -445,7 +445,7 @@ "label": "Cyclomatic", "name": "Cyclomatic complexity", "omit_at": 1.0, - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/Cyclomatic.md", + "remediation": "Run `code-ranker report --doc Cyclomatic` and follow its instructions.", "short": "Cyclomatic", "value_type": "int" }, @@ -501,7 +501,7 @@ "group": "coupling", "label": "Fan-in", "name": "Fan-in", - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/Fan-in.md", + "remediation": "Run `code-ranker report --doc Fan-in` and follow its instructions.", "short": "Fan-in", "value_type": "int" }, @@ -510,7 +510,7 @@ "group": "coupling", "label": "Fan-out", "name": "Fan-out", - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/Fan-out.md", + "remediation": "Run `code-ranker report --doc Fan-out` and follow its instructions.", "short": "Fan-out", "value_type": "int" }, @@ -531,7 +531,7 @@ "group": "coupling", "label": "HK", "name": "Henry–Kafura", - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/HK.md", + "remediation": "Run `code-ranker report --doc HK` and follow its instructions.", "short": "HK", "value_type": "float" }, @@ -1590,7 +1590,7 @@ } }, "plugin": "rust", - "presets": [ + "principles": [ { "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/rust/CPX.md", "id": "CPX", diff --git a/crates/code-ranker-plugins/src/languages/typescript/config.toml b/crates/code-ranker-plugins/src/languages/typescript/config.toml index a6196f42..26086dc8 100644 --- a/crates/code-ranker-plugins/src/languages/typescript/config.toml +++ b/crates/code-ranker-plugins/src/languages/typescript/config.toml @@ -5,7 +5,7 @@ # same engine). This file therefore carries only what is TypeScript-specific to # the language-neutral config layer: its principle-corpus language for doc links. # -# TypeScript adds NO metric-lens presets of its own — it inherits the common +# TypeScript adds NO metric-lens principles of its own — it inherits the common # catalog from `defaults.toml`. `doc_url` = `{doc_base}/typescript/<slug>.md`. # Principle-corpus language for doc links (`doc_base` inherited from defaults). diff --git a/crates/code-ranker-plugins/src/languages/typescript/mod.rs b/crates/code-ranker-plugins/src/languages/typescript/mod.rs index e47cb768..ba5fa522 100644 --- a/crates/code-ranker-plugins/src/languages/typescript/mod.rs +++ b/crates/code-ranker-plugins/src/languages/typescript/mod.rs @@ -10,7 +10,7 @@ use crate::languages::ecmascript::{ }; use anyhow::Result; use code_ranker_plugin_api::{ - Preset, detect_with_marker, + Principle, detect_with_marker, graph::Graph, level::{AttributeSpec, Level}, metrics::MetricInputs, @@ -93,10 +93,10 @@ impl LanguagePlugin for TypescriptPlugin { ecmascript_function_units(graph, grammar_for) } - fn presets(&self, _input: &PluginInput) -> Vec<Preset> { + fn principles(&self, _input: &PluginInput) -> Vec<Principle> { // The common catalog from `defaults.toml`, with `doc_url` resolved to - // `{doc_base}/typescript/<slug>.md` (TypeScript adds no presets of its own). - crate::config::resolved_presets(&CONFIG) + // `{doc_base}/typescript/<slug>.md` (TypeScript adds no principles of its own). + crate::config::resolved_principles(&CONFIG) } fn report_overrides(&self) -> code_ranker_plugin_api::report::ReportOverride { diff --git a/crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker-report.json b/crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker-report.json index 9ee23948..6589a095 100644 --- a/crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker-report.json +++ b/crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker-report.json @@ -36,12 +36,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": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/ADP.md" + "remediation": "Run `code-ranker report --doc 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": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/ADP.md" + "remediation": "Run `code-ranker report --doc ADP` and follow its instructions." } }, "cycles": [ @@ -204,7 +204,7 @@ "group": "complexity", "label": "Cognitive", "name": "Cognitive complexity", - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/Cognitive.md", + "remediation": "Run `code-ranker report --doc Cognitive` and follow its instructions.", "short": "Cognitive", "value_type": "int" }, @@ -224,7 +224,7 @@ "label": "Cyclomatic", "name": "Cyclomatic complexity", "omit_at": 1.0, - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/Cyclomatic.md", + "remediation": "Run `code-ranker report --doc Cyclomatic` and follow its instructions.", "short": "Cyclomatic", "value_type": "int" }, @@ -275,7 +275,7 @@ "group": "coupling", "label": "Fan-in", "name": "Fan-in", - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/Fan-in.md", + "remediation": "Run `code-ranker report --doc Fan-in` and follow its instructions.", "short": "Fan-in", "value_type": "int" }, @@ -284,7 +284,7 @@ "group": "coupling", "label": "Fan-out", "name": "Fan-out", - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/Fan-out.md", + "remediation": "Run `code-ranker report --doc Fan-out` and follow its instructions.", "short": "Fan-out", "value_type": "int" }, @@ -305,7 +305,7 @@ "group": "coupling", "label": "HK", "name": "Henry–Kafura", - "remediation": "Download and follow the instructions on https://github.com/ffedoroff/code-ranker/blob/main/languages/base/HK.md", + "remediation": "Run `code-ranker report --doc HK` and follow its instructions.", "short": "HK", "value_type": "float" }, @@ -860,7 +860,7 @@ } }, "plugin": "typescript", - "presets": [ + "principles": [ { "doc_url": "https://github.com/ffedoroff/code-ranker/blob/main/languages/typescript/CPX.md", "id": "CPX", diff --git a/crates/code-ranker-plugins/src/tests/config.rs b/crates/code-ranker-plugins/src/tests/config.rs index d56130b0..eadabc22 100644 --- a/crates/code-ranker-plugins/src/tests/config.rs +++ b/crates/code-ranker-plugins/src/tests/config.rs @@ -1,6 +1,6 @@ use super::*; use code_ranker_plugin_api::list_override::report_override; -use code_ranker_plugin_api::toml_merge::{deep_merge, merge_presets}; +use code_ranker_plugin_api::toml_merge::{deep_merge, merge_principles}; use toml::{Table, Value}; /// Deep-merge recurses into tables and lets the overlay win per key. @@ -34,32 +34,32 @@ fn deep_merge_replaces_table_with_scalar() { assert_eq!(merged["a"].as_integer(), Some(5)); } -/// `[[presets]]` arrays merge by `id`: a same-id overlay preset replaces the +/// `[[principles]]` arrays merge by `id`: a same-id overlay principle replaces the /// base entry in place; a new id appends. #[test] -fn presets_merge_by_id() { +fn principles_merge_by_id() { let base: Table = r#" -[[presets]] +[[principles]] id = "A" title = "base A" -[[presets]] +[[principles]] id = "B" title = "base B" "# .parse() .unwrap(); let overlay: Table = r#" -[[presets]] +[[principles]] id = "B" title = "overlay B" -[[presets]] +[[principles]] id = "C" title = "overlay C" "# .parse() .unwrap(); let merged = deep_merge(base, overlay); - let arr = merged["presets"].as_array().unwrap(); + let arr = merged["principles"].as_array().unwrap(); let ids: Vec<&str> = arr.iter().map(|p| p["id"].as_str().unwrap()).collect(); assert_eq!(ids, ["A", "B", "C"], "B replaced in place, C appended"); // B took the overlay's title. @@ -67,30 +67,30 @@ title = "overlay C" assert_eq!(b["title"].as_str(), Some("overlay B")); } -/// A preset without a string `id` is appended verbatim rather than matched. +/// A principle without a string `id` is appended verbatim rather than matched. #[test] -fn presets_without_id_are_appended() { +fn principles_without_id_are_appended() { let base = vec![toml::Value::Table({ let mut t = Table::new(); t.insert("id".into(), "A".into()); t })]; let overlay = vec![toml::Value::Table(Table::new())]; - let merged = merge_presets(base, overlay); + let merged = merge_principles(base, overlay); assert_eq!(merged.len(), 2); } /// The shared loader merges `defaults.toml` under `rust.toml` and exposes the -/// Rust presets / spec overrides. +/// Rust principles / spec overrides. #[test] fn load_rust_exposes_sections() { let cfg = load(include_str!("../languages/rust/config.toml")); - let ps = presets(&cfg); + let ps = principles(&cfg); let ids: Vec<&str> = ps.iter().map(|p| p.id.as_str()).collect(); // The full catalog is inherited from `defaults.toml`: 13 design principles. - // The metric-lens presets (HK/SLOC/FANIN/FANOUT) were removed — each metric - // now carries its own prompt doc. Rust adds no own presets. + // The metric-lens principles (HK/SLOC/FANIN/FANOUT) were removed — each metric + // now carries its own prompt doc. Rust adds no own principles. assert_eq!( ids, [ @@ -311,18 +311,18 @@ fn string_list_reads_data_lists_verbatim() { } /// The common catalog lives in `defaults.toml` and is inherited by every -/// language; `resolved_presets` returns it (catalog first, language presets +/// language; `resolved_principles` returns it (catalog first, language principles /// appended) with `label = id`. Each `doc_url` resolves to its own /// `{doc_base}/{doc_lang}/{id}.md` for a language that overrides the id /// (`doc_overrides`), and to the shared `{doc_base}/base/{id}.md` fallback /// otherwise. #[test] -fn resolved_presets_inherit_catalog_and_resolve_doc_urls() { - let rust = resolved_presets(&load(include_str!("../languages/rust/config.toml"))); +fn resolved_principles_inherit_catalog_and_resolve_doc_urls() { + let rust = resolved_principles(&load(include_str!("../languages/rust/config.toml"))); let ids: Vec<&str> = rust.iter().map(|p| p.id.as_str()).collect(); // The full catalog in `defaults.toml` order: 13 design principles. All come - // from `defaults.toml` (inherited by every language); Rust adds no own presets. - // (The metric-lens presets were removed — metrics carry their own docs now.) + // from `defaults.toml` (inherited by every language); Rust adds no own principles. + // (The metric-lens principles were removed — metrics carry their own docs now.) assert_eq!( ids, [ @@ -339,18 +339,18 @@ fn resolved_presets_inherit_catalog_and_resolve_doc_urls() { Some("https://github.com/ffedoroff/code-ranker/blob/main/languages/rust/CPX.md") ); - // Languages with no own presets inherit the full 13-entry catalog, and JS + // Languages with no own principles inherit the full 13-entry catalog, and JS // shares the TypeScript corpus. - let py = resolved_presets(&load(include_str!("../languages/python/config.toml"))); + let py = resolved_principles(&load(include_str!("../languages/python/config.toml"))); assert_eq!(py.len(), 13); assert!(py[0].doc_url.as_deref().unwrap().contains("/python/")); - let js = resolved_presets(&load(include_str!("../languages/javascript/config.toml"))); + let js = resolved_principles(&load(include_str!("../languages/javascript/config.toml"))); assert_eq!(js.len(), 13); assert!(js[0].doc_url.as_deref().unwrap().contains("/typescript/")); // A language with no own corpus (no `doc_overrides`) inherits every doc from // the shared `base/` fallback — fixing what used to be a dead `/go/` link. - let go = resolved_presets(&load(include_str!("../languages/go/config.toml"))); + let go = resolved_principles(&load(include_str!("../languages/go/config.toml"))); assert_eq!(go.len(), 13); assert_eq!( go[0].doc_url.as_deref(), @@ -366,11 +366,11 @@ fn resolved_presets_inherit_catalog_and_resolve_doc_urls() { /// The selective `doc_overrides = ["SRP", …]` form: only the listed ids resolve /// to the language's own folder; every other principle falls back to `base/`. #[test] -fn resolved_presets_partial_doc_overrides_route_only_listed_ids() { +fn resolved_principles_partial_doc_overrides_route_only_listed_ids() { let cfg = load("doc_lang = \"mylang\"\ndoc_overrides = [\"SRP\", \"DIP\"]\n"); - let presets = resolved_presets(&cfg); + let principles = resolved_principles(&cfg); let url = |id: &str| { - presets + principles .iter() .find(|p| p.id == id) .and_then(|p| p.doc_url.clone()) @@ -410,13 +410,13 @@ fn deep_merge_patches_inherited_lists_via_op_table() { assert_eq!(strs(&merged["ys"]), ["replaced"], "plain array replaces"); } -/// `presets` merges by id only array-vs-array; a non-array overlay replaces it. +/// `principles` merges by id only array-vs-array; a non-array overlay replaces it. #[test] -fn presets_replaced_by_non_array_overlay() { - let base: Table = "presets = [{ id = \"a\" }]\n".parse().unwrap(); - let overlay: Table = "presets = \"none\"\n".parse().unwrap(); +fn principles_replaced_by_non_array_overlay() { + let base: Table = "principles = [{ id = \"a\" }]\n".parse().unwrap(); + let overlay: Table = "principles = \"none\"\n".parse().unwrap(); let merged = deep_merge(base, overlay); - assert_eq!(merged["presets"].as_str(), Some("none")); + assert_eq!(merged["principles"].as_str(), Some("none")); } /// Integration: the real Rust config carries the demo `[report]` override — five diff --git a/crates/code-ranker-viewer/src/assets/export-popup.js b/crates/code-ranker-viewer/src/assets/export-popup.js index 9ade8d94..563887fd 100644 --- a/crates/code-ranker-viewer/src/assets/export-popup.js +++ b/crates/code-ranker-viewer/src/assets/export-popup.js @@ -29,15 +29,15 @@ window.isPromptPopupOpen = isPromptPopupOpen; // ── Prompt-Generator state in the URL ──────────────────────────────────────── // The popup persists its full state in the query string so a refresh restores it -// exactly (open state, preset, source, count, sort metric, connection toggles, +// exactly (open state, principle, source, count, sort metric, connection toggles, // and the selected node ids). `epsel` is repeated once per selected id. -const EP_KEYS = ['ep', 'eppreset', 'epsrc', 'epn', 'epsort', 'epconn', 'epsel']; +const EP_KEYS = ['ep', 'epprinciple', 'epsrc', 'epn', 'epsort', 'epconn', 'epsel']; function epWriteUrlState(s) { const p = new URLSearchParams(location.search); EP_KEYS.forEach(k => p.delete(k)); p.set('ep', s.level); - if (s.preset) p.set('eppreset', s.preset); + if (s.principle) p.set('epprinciple', s.principle); p.set('epsrc', s.src === 'selected' ? 'sel' : 'rec'); if (s.n != null && s.n !== '') p.set('epn', String(s.n)); if (s.sort) p.set('epsort', s.sort); @@ -51,7 +51,7 @@ function epReadUrl() { if (!p.has('ep')) return null; return { level: p.get('ep'), - preset: p.get('eppreset') || null, + principle: p.get('epprinciple') || null, src: p.get('epsrc') || null, n: p.get('epn'), sort: p.get('epsort') || null, @@ -91,7 +91,7 @@ function openExportPopup(level, restore) { // ── popup DOM (created once) ────────────────────────────────────────── let overlay = document.getElementById('export-popup-overlay'); if (!overlay) { - const presets = snapshotPresets(); + const principles = snapshotPrinciples(); const ui = levelUi(level); const sortMetrics = ui.sort || ['hk']; @@ -100,8 +100,8 @@ function openExportPopup(level, restore) { return `<option value="${m}">${label}</option>`; }).join(''); - const presetBtns = presets.map(p => - `<button class="exp-preset-btn" data-preset="${p.id}">${p.label}<span class="exp-preset-count"></span></button>` + const principleBtns = principles.map(p => + `<button class="exp-principle-btn" data-principle="${p.id}">${p.label}<span class="exp-principle-count"></span></button>` ).join(''); overlay = document.createElement('div'); @@ -131,9 +131,9 @@ function openExportPopup(level, restore) { '<textarea id="export-textarea" readonly></textarea>' + '<button class="exp-copy-btn">Copy markdown <span class="exp-copy-icon">⎘</span></button>' + '</div>' + - '<div class="exp-presets">' + - '<div class="exp-presets-label">Presets</div>' + - `<div class="exp-preset-btns">${presetBtns}</div>` + + '<div class="exp-principles">' + + '<div class="exp-principles-label">Principles</div>' + + `<div class="exp-principle-btns">${principleBtns}</div>` + '</div>' + '</div>'; document.body.appendChild(overlay); @@ -153,15 +153,15 @@ function openExportPopup(level, restore) { }); } - // Wrap a preset's title + prompt into the full instruction the AI receives: + // 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 // research/report protocol (report violations in the modules below, save the // report to `.code-ranker/<timestamp>-<id>.md`). const composePrompt = id => { - const preset = snapshotPresets().find(p => p.id === id); - if (!preset) return ''; - const { title, prompt: summary, doc_url: url } = preset; + const principle = snapshotPrinciples().find(p => p.id === id); + if (!principle) return ''; + const { title, prompt: summary, doc_url: url } = principle; // Scaffolding prose is DATA from the snapshot's `prompt` template — the same // source the CLI `prompt` format reads, so the two render identical text. const t = snapshotPrompt(); @@ -188,7 +188,7 @@ function openExportPopup(level, restore) { // Rebind handlers each open (closures capture fresh selNodes/edges) const ta = document.getElementById('export-textarea'); - let activePresetKey = null; + let activePrincipleKey = null; const internalNodes = () => allNodes.filter(n => !isExternalNode(n, level) && n.status !== 'removed'); @@ -227,7 +227,7 @@ function openExportPopup(level, restore) { // opening the popup on one side would drop the other side's selections on reload. const epWriteUrl = () => epWriteUrlState({ level, - preset: activePresetKey, + principle: activePrincipleKey, src: overlay.querySelector('input[name="exp-source"]:checked')?.value, n: recCount.value, sort: sortSel.value, @@ -254,35 +254,35 @@ function openExportPopup(level, restore) { if (c > 0 && c <= r.warningCount) recCount.classList.add('exp-rec-warn'); }; - // Selecting a preset points the sort dropdown at its metric and sets the count - // to that preset's headline recommendation (warning count if any, else info). + // Selecting a principle points the sort dropdown at its metric and sets the count + // to that principle's headline recommendation (warning count if any, else info). const updateRecoUI = id => { - const preset = id ? snapshotPresets().find(p => p.id === id) : null; - const metric = preset?.sort_metric || levelUi(level).default_sort || sortSel.options[0]?.value; + const principle = id ? snapshotPrinciples().find(p => p.id === id) : null; + const metric = principle?.sort_metric || levelUi(level).default_sort || sortSel.options[0]?.value; if (metric) sortSel.value = metric; const r = recoFor(sortSel.value); recCount.value = String(r.warningCount > 0 ? r.warningCount : r.infoCount); colorCount(); }; - // Per-preset badge: warning-level count as a calm text-colour pill (a label); + // Per-principle badge: warning-level count as a calm text-colour pill (a label); // info-level count as a plain number (no pill, no emphasis); else nothing. - const updatePresetBadges = () => { - overlay.querySelectorAll('.exp-preset-btn').forEach(btn => { - const badge = btn.querySelector('.exp-preset-count'); + const updatePrincipleBadges = () => { + overlay.querySelectorAll('.exp-principle-btn').forEach(btn => { + const badge = btn.querySelector('.exp-principle-count'); if (!badge) return; - const preset = snapshotPresets().find(p => p.id === btn.dataset.preset); - const metric = preset?.sort_metric || levelUi(level).default_sort || sortSel.options[0]?.value; + const principle = snapshotPrinciples().find(p => p.id === btn.dataset.principle); + const metric = principle?.sort_metric || levelUi(level).default_sort || sortSel.options[0]?.value; const r = recoFor(metric); if (r.warningCount > 0) { badge.textContent = String(r.warningCount); - badge.className = 'exp-preset-count exp-preset-count--warn'; + badge.className = 'exp-principle-count exp-principle-count--warn'; } else if (r.infoCount > 0) { badge.textContent = String(r.infoCount); - badge.className = 'exp-preset-count exp-preset-count--info'; + badge.className = 'exp-principle-count exp-principle-count--info'; } else { badge.textContent = ''; - badge.className = 'exp-preset-count'; + badge.className = 'exp-principle-count'; } }); }; @@ -308,8 +308,8 @@ function openExportPopup(level, restore) { const on = id => { const c = cbs.find(c => c.dataset.mode === id); return !!(c && !c.disabled && c.checked); }; const parts = []; - if (activePresetKey) { - const p = composePrompt(activePresetKey); + if (activePrincipleKey) { + const p = composePrompt(activePrincipleKey); if (p) parts.push(p); } // Node paths are always included (the modules the prompt is about). In @@ -334,9 +334,9 @@ function openExportPopup(level, restore) { // dropped (it lives in `--doc <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 presetPrompt = snapshotPresets().find(p => p.id === activePresetKey)?.prompt; + const principlePrompt = snapshotPrinciples().find(p => p.id === activePrincipleKey)?.prompt; const desc = attrDesc(level, m); - const intro = (desc && desc !== presetPrompt) ? desc : ''; + const intro = (desc && desc !== principlePrompt) ? desc : ''; parts.push([heading, intro, lines].filter(Boolean).join('\n\n')); } } else { @@ -390,33 +390,33 @@ function openExportPopup(level, restore) { buildContent(); }; - const applyPresetChecks = id => { - const preset = id ? snapshotPresets().find(p => p.id === id) : null; + const applyPrincipleChecks = id => { + const principle = id ? snapshotPrinciples().find(p => p.id === id) : null; // connections values in the snapshot: "in" / "out" / "common" → map to data-mode const connMap = { in: 'conn-in', out: 'conn-out', common: 'conn-common' }; - const active = (preset?.connections || []).map(c => connMap[c]).filter(Boolean); + const active = (principle?.connections || []).map(c => connMap[c]).filter(Boolean); overlay.querySelectorAll('.exp-mode-cb input').forEach(cb => { cb.checked = active.includes(cb.dataset.mode); }); }; - overlay.querySelectorAll('.exp-preset-btn').forEach(btn => { + overlay.querySelectorAll('.exp-principle-btn').forEach(btn => { btn.onclick = () => { - const key = btn.dataset.preset; - if (activePresetKey === key) { - activePresetKey = null; - btn.classList.remove('exp-preset-btn--active'); - applyPresetChecks(null); + const key = btn.dataset.principle; + if (activePrincipleKey === key) { + activePrincipleKey = null; + btn.classList.remove('exp-principle-btn--active'); + applyPrincipleChecks(null); } else { - activePresetKey = key; - overlay.querySelectorAll('.exp-preset-btn').forEach(b => b.classList.remove('exp-preset-btn--active')); - btn.classList.add('exp-preset-btn--active'); - applyPresetChecks(key); - // Switch to Recommended and size the count to this preset's recommendation. + activePrincipleKey = key; + overlay.querySelectorAll('.exp-principle-btn').forEach(b => b.classList.remove('exp-principle-btn--active')); + btn.classList.add('exp-principle-btn--active'); + applyPrincipleChecks(key); + // Switch to Recommended and size the count to this principle's recommendation. const rec = overlay.querySelector('input[name="exp-source"][value="recommended"]'); if (rec) rec.checked = true; } - updateRecoUI(activePresetKey); + updateRecoUI(activePrincipleKey); buildContent(); }; }); @@ -436,19 +436,19 @@ function openExportPopup(level, restore) { if (selCountEl) selCountEl.textContent = String(selNodes.length); if (restore) { - // Restore from the URL: preset, source, count, sort metric, connection toggles. - activePresetKey = restore.preset || null; - overlay.querySelectorAll('.exp-preset-btn').forEach(b => - b.classList.toggle('exp-preset-btn--active', b.dataset.preset === activePresetKey)); + // Restore from the URL: principle, source, count, sort metric, connection toggles. + activePrincipleKey = restore.principle || null; + overlay.querySelectorAll('.exp-principle-btn').forEach(b => + b.classList.toggle('exp-principle-btn--active', b.dataset.principle === activePrincipleKey)); const srcVal = restore.src === 'sel' ? 'selected' : 'recommended'; overlay.querySelectorAll('input[name="exp-source"]').forEach(r => { r.checked = r.value === srcVal; }); if (restore.sort) sortSel.value = restore.sort; recCount.value = (restore.n != null && restore.n !== '') ? restore.n : '1'; overlay.querySelectorAll('.exp-mode-cb input').forEach(c => { c.checked = restore.conn.includes(c.dataset.mode); }); } else { - // Fresh open: only paths, no active preset; seed the criterion from default. - activePresetKey = null; - overlay.querySelectorAll('.exp-preset-btn').forEach(b => b.classList.remove('exp-preset-btn--active')); + // Fresh open: only paths, no active principle; seed the criterion from default. + activePrincipleKey = null; + overlay.querySelectorAll('.exp-principle-btn').forEach(b => b.classList.remove('exp-principle-btn--active')); overlay.querySelectorAll('.exp-mode-cb input').forEach(c => { c.checked = false; }); overlay.querySelectorAll('input[name="exp-source"]').forEach(r => { r.checked = noSel ? r.value === 'recommended' : r.value === 'selected'; @@ -459,7 +459,7 @@ function openExportPopup(level, restore) { recCount.value = '1'; // default: recommend 1 row } colorCount(); - updatePresetBadges(); // count badges on each preset button + updatePrincipleBadges(); // count badges on each principle button buildContent(); // also mirrors state into the URL // Reflect the active side in the title: Prompt Generator / … Baseline / … Current. const titleEl = document.getElementById('export-popup-title'); diff --git a/crates/code-ranker-viewer/src/assets/export.css b/crates/code-ranker-viewer/src/assets/export.css index 46074eca..8e735513 100644 --- a/crates/code-ranker-viewer/src/assets/export.css +++ b/crates/code-ranker-viewer/src/assets/export.css @@ -59,29 +59,29 @@ background: #eef2f7; border: 1px solid #e0e6ee; border-radius: 3px; padding: 1px 4px; color: #b3801f; } .exp-md-preview strong { color: #15324f; } -.exp-presets { padding: 8px 18px; border-top: 1px solid #edf1f5; } -.exp-presets-label { font-size: 11px; color: #888; text-transform: uppercase; +.exp-principles { padding: 8px 18px; border-top: 1px solid #edf1f5; } +.exp-principles-label { font-size: 11px; color: #888; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 6px; } -/* Equal-width preset buttons in a grid; cells stretch to equal height so a +/* Equal-width principle buttons in a grid; cells stretch to equal height so a button with a count badge is the same size as one without. */ -.exp-preset-btns { display: grid; grid-template-columns: repeat(auto-fit, minmax(70px, 1fr)); gap: 5px; } -.exp-preset-btn { padding: 3px 6px; font-size: 11px; font-family: inherit; border-radius: 4px; +.exp-principle-btns { display: grid; grid-template-columns: repeat(auto-fit, minmax(70px, 1fr)); gap: 5px; } +.exp-principle-btn { padding: 3px 6px; font-size: 11px; font-family: inherit; border-radius: 4px; border: 1px solid #c8d4e0; background: #f8fafc; color: #2c3e50; cursor: pointer; display: flex; flex-direction: row; align-items: center; gap: 4px; text-align: left; } -.exp-preset-btn:hover { background: #dbe9f4; border-color: #4d6f9c; } -/* Low-sensitivity UI: presets get no button-level emphasis (border/colour) for +.exp-principle-btn:hover { background: #dbe9f4; border-color: #4d6f9c; } +/* Low-sensitivity UI: principles get no button-level emphasis (border/colour) for info or warning — only the count badge differs. */ -.exp-preset-btn--active { background: #2c6fad; border-color: #2c6fad; color: #fff; } -.exp-preset-btn--active:hover { background: #2259a0; border-color: #2259a0; } +.exp-principle-btn--active { background: #2c6fad; border-color: #2c6fad; color: #fff; } +.exp-principle-btn--active:hover { background: #2259a0; border-color: #2259a0; } /* Recommendation count badge. `--warn`: a filled pill in the text colour (a calm label/background, not red) with a light digit. `--info`: a plain right-aligned number — no pill, no highlight. Collapses to nothing when there is no count. */ -.exp-preset-count { font-size: 10px; font-weight: 700; line-height: 16px; color: #fff; +.exp-principle-count { font-size: 10px; font-weight: 700; line-height: 16px; color: #fff; display: inline-block; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 999px; text-align: center; margin-left: auto; } -.exp-preset-count:empty { display: none; } -.exp-preset-count--warn { background: #2c3e50; } -.exp-preset-count--info { background: none; color: inherit; min-width: 0; height: auto; +.exp-principle-count:empty { display: none; } +.exp-principle-count--warn { background: #2c3e50; } +.exp-principle-count--info { background: none; color: inherit; min-width: 0; height: auto; padding: 0; line-height: inherit; border-radius: 0; } .exp-copy-btn { position: absolute; bottom: 10px; right: 10px; width: 150px; height: 36px; padding: 0; diff --git a/crates/code-ranker-viewer/src/assets/schema.js b/crates/code-ranker-viewer/src/assets/schema.js index 6bfdf1f9..846b3b4d 100644 --- a/crates/code-ranker-viewer/src/assets/schema.js +++ b/crates/code-ranker-viewer/src/assets/schema.js @@ -1,10 +1,10 @@ // schema.js — the ONLY place that knows the snapshot JSON shape. // // The viewer is a pure renderer: every metric label, description, formula, -// threshold, colour, preset and prompt comes from the snapshot's per-level +// threshold, colour, principle and prompt comes from the snapshot's per-level // dictionaries (node_attributes / edge_attributes / edge_kinds / // attribute_groups / node_kinds / cycle_kinds / ui) and the top-level -// `presets`. No metric/kind is hardcoded by name anywhere in the frontend. +// `principles`. No metric/kind is hardcoded by name anywhere in the frontend. // The level's dictionaries (specs) — read from the active snapshot, which is the // authority for how to render. Falls back to the other side so a single-snapshot @@ -67,10 +67,10 @@ function edgeKindDesc(level, kind) { return levelSpec(level).edge_kinds?.[kind] function cycleKindLabel(level, kind) { return levelSpec(level).cycle_kinds?.[kind]?.label || kind; } function cycleKindDesc(level, kind) { return levelSpec(level).cycle_kinds?.[kind]?.description || ''; } -// ── UI hints / groups / presets ──────────────────────────────────────────── +// ── UI hints / groups / principles ──────────────────────────────────────────── function levelUi(level) { return levelSpec(level).ui || {}; } function attributeGroups(level){ return levelSpec(level).attribute_groups || {}; } -function snapshotPresets() { return specSnap()?.presets || []; } +function snapshotPrinciples() { return specSnap()?.principles || []; } // Prompt-Generator scaffolding prose (intro / doc_note / task / focus / cycle_note), // carried in the snapshot so this matches the CLI `prompt` format from one source. function snapshotPrompt() { return specSnap()?.prompt || {}; } diff --git a/docs/DESIGN.md b/docs/DESIGN.md index edfe910a..4e107949 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -282,12 +282,12 @@ keys it understands, described per level by the semantics dictionaries. | AttributeSpec | Everything the UI needs to render a metric from data: `value_type`, `label`, `name` (tooltip title), `short` (table header), `description` (the diagnostic *why*), `remediation` (the diagnostic *fix* — both shown by `check`, data not Rust), `formula` (display), `calc` (an `eval`-able JS expression over sibling attrs — the live derivation), `direction` (`higher_better`/`lower_better`, for delta colour; **absent → the Δ stays neutral / uncoloured** — used for raw sizes like `sloc`/`lloc`/`blank` and for `fan_in`/`fan_out` (high coupling is dual — a tangled unit or a legitimate coordinator — so the directional signal lives in `hk` only), which have no agreed "good" way to move), `abbreviate` (K/M — **viewer-only**: the CLI scorecard/prompt always print exact integers), `group`, `thresholds {info, warning}`. All optional but `value_type`. | `crates/code-ranker-plugin-api/src/level.rs` | | NodeKindSpec / CycleKindSpec | Per-kind UI semantics. `NodeKindSpec`: `label`/`plural`/`fill`/`stroke`/`external`. `CycleKindSpec`: `label`/`description` (the cycle *why*)/`remediation` (the *fix*). `default_node_kinds()` seeds node kinds; `default_cycle_kinds()` seeds only the cycle *keys* (`mutual`/`chain`) — the orchestrator overlays the vocabulary centrally from `code-ranker-graph`'s `cycle_specs()` (the `builtin.toml [cycles.*]` catalog), so no cycle prose lives in Rust. | `crates/code-ranker-plugin-api/src/level.rs` | | Thresholds | `{ info: f64, warning: f64 }` — two-tier per-metric advisory thresholds overlaid onto the matching `AttributeSpec`; `warning` is the `[rules.thresholds.file]` gate limit, `info` an optional softer line below it (so the report mirrors the gate). | `crates/code-ranker-plugin-api/src/level.rs` | -| Preset | A Prompt-Generator principle: `id`, `label`, `title`, `prompt`, `doc_url?`, `sort_metric`, `connections`. The orchestrator builds a generic default catalog (`code-ranker-cli/src/presets.rs`) and a plugin's `presets(input)` hook may pass through / edit / extend it. Stored top-level in the snapshot. Prompt-generator domain data — lives in its own module, not the parser contract. | `crates/code-ranker-plugin-api/src/preset.rs` | -| PromptTemplate | The language-neutral prompt **scaffolding** the Prompt-Generator wraps a `Preset` in: `intro`, `doc_note`, `task` (bullet lines), `focus`, `cycle_note` (`{id}` substituted at render). Data, not code — sourced from `code-ranker-graph/metrics/prompt.md` (`code-ranker-graph`'s `prompt_template()`), carried top-level in the snapshot so the CLI `prompt` format and the viewer's Prompt Generator render the same text from one source. | `crates/code-ranker-plugin-api/src/preset.rs` | +| Principle | A Prompt-Generator principle: `id`, `label`, `title`, `prompt`, `doc_url?`, `sort_metric`, `connections`. The orchestrator builds a generic default catalog (`code-ranker-cli/src/principles.rs`) and a plugin's `principles(input)` hook may pass through / edit / extend it. Stored top-level in the snapshot. Prompt-generator domain data — lives in its own module, not the parser contract. | `crates/code-ranker-plugin-api/src/principle.rs` | +| PromptTemplate | The language-neutral prompt **scaffolding** the Prompt-Generator wraps a `Principle` in: `intro`, `doc_note`, `task` (bullet lines), `focus`, `cycle_note` (`{id}` substituted at render). Data, not code — sourced from `code-ranker-graph/metrics/prompt.md` (`code-ranker-graph`'s `prompt_template()`), carried top-level in the snapshot so the CLI `prompt` format and the viewer's Prompt Generator render the same text from one source. | `crates/code-ranker-plugin-api/src/principle.rs` | | CycleGroup | SCC with ≥ 2 nodes: `kind: String` (`"mutual"` for a 2-node SCC, `"chain"` for 3+), `nodes: Vec<NodeId>`. Each member node also carries a `cycle` attribute. | `crates/code-ranker-graph/src/level_graph.rs` | | LevelUi | Computed UI hints: `default_sort`, `sort`, `size`, `card`, `columns`, `summary` — each a curated metric order filtered to the attributes present on internal nodes, so the viewer renders them verbatim and hardcodes none of it — plus an optional `grouping` (carried through from the level spec, pruned to a usable attribute) telling the viewer how to cluster diagram nodes. | `crates/code-ranker-graph/src/level_graph.rs` | | LevelGraph | One analysis level in the snapshot: the semantics dictionaries (`edge_kinds`/`node_attributes`/`edge_attributes`/`attribute_groups`/`node_kinds`/`cycle_kinds`) + `nodes` + `edges` + `cycles: Vec<CycleGroup>` + `stats: BTreeMap<String, AttrValue>` (flat averages) + `ui: LevelUi`. | `crates/code-ranker-graph/src/level_graph.rs` | -| Snapshot | The `.json` artifact: `schema_version: "3"`, `generated_at`, `command`, `workspace`, `target`, `plugin`, `config_file?`, `versions`, `roots`, `git?`, `timings`, `graphs: BTreeMap<String, LevelGraph>`, top-level `presets: Vec<Preset>`, and `prompt: PromptTemplate` (the Prompt-Generator scaffolding prose, read by both the CLI and the viewer). Serialized via `to_canonical_string_pretty` — **canonical JSON** (alphabetical keys; `nodes`/`edges` sorted). | `crates/code-ranker-graph/src/snapshot.rs` | +| Snapshot | The `.json` artifact: `schema_version: "3"`, `generated_at`, `command`, `workspace`, `target`, `plugin`, `config_file?`, `versions`, `roots`, `git?`, `timings`, `graphs: BTreeMap<String, LevelGraph>`, top-level `principles: Vec<Principle>`, and `prompt: PromptTemplate` (the Prompt-Generator scaffolding prose, read by both the CLI and the viewer). Serialized via `to_canonical_string_pretty` — **canonical JSON** (alphabetical keys; `nodes`/`edges` sorted). | `crates/code-ranker-graph/src/snapshot.rs` | | StageTime | Per-stage timing entry: `stage`, `ms`, `detail`. Stored in `Snapshot.timings` in execution order. | `crates/code-ranker-graph/src/snapshot.rs` | **Relationships**: @@ -341,7 +341,7 @@ Modules: - **`finalize.rs`** — `finalize_graph`: drop self-loops, dedup edges on `(source, target, kind)`, prune unreferenced external nodes, sort. - **`snapshot.rs`** — the top-level `Snapshot` artifact (`schema_version`, - header, `graphs` map, `presets`) plus its header types `GitInfo` / `StageTime`. + header, `graphs` map, `principles`) plus its header types `GitInfo` / `StageTime`. - **`level_graph.rs`** — the widely-imported per-level payload types: `LevelGraph` (graph + semantics dictionaries + computed cycles/stats/UI), `LevelUi`, and `CycleGroup`. Split out from `snapshot.rs` so their fan-in lands here, not on @@ -1011,7 +1011,7 @@ dictionaries with the structural graph and the computed cycles/stats: "stats": { "cyclomatic": 1, "hk": 240, "sloc": 26, … } } }, - "presets": [ { "id": "ADP", "title": "…", "prompt": "…", "doc_url": "…", "sort_metric": "cycle", "connections": ["common","out"] }, … ] + "principles": [ { "id": "ADP", "title": "…", "prompt": "…", "doc_url": "…", "sort_metric": "cycle", "connections": ["common","out"] }, … ] } ``` @@ -1020,7 +1020,7 @@ objects); a file node carries no `path` (its id IS its path); an edge is external iff its `target` is an `ext:` node (no `edge.external`). Every metric's label/name/formula/`calc`/direction/threshold is in `node_attributes`, node/cycle kinds in `node_kinds`/`cycle_kinds`, column/sort ordering in `ui`, and the -Prompt-Generator principles in top-level `presets` — so the viewer renders +Prompt-Generator principles in top-level `principles` — so the viewer renders entirely from this data and hardcodes none of it. `workspace` is the directory where `code-ranker` was invoked (cwd). `target` @@ -1225,13 +1225,13 @@ extension, not part of the base flat-attribute schema.) code-ranker/ crates/ code-ranker-graph/ # Rust — graph types, JSON schema, StageTime, cycles/hk/stats + language-neutral metric scaffolding (write_metrics, metric_specs; metrics/builtin.toml catalog) - code-ranker-plugin-api/ # Rust — the LanguagePlugin trait (+ PluginInput) & its self-registering plugin registry (PluginRegistration/registry, inventory) in plugin.rs, the Preset DTO (preset.rs), the detect_with_marker helper (detection.rs), MetricInputs/FunctionUnit; shared TOML config utilities (toml_merge deep-merge, list_override DSL) + code-ranker-plugin-api/ # Rust — the LanguagePlugin trait (+ PluginInput) & its self-registering plugin registry (PluginRegistration/registry, inventory) in plugin.rs, the Principle DTO (principle.rs), the detect_with_marker helper (detection.rs), MetricInputs/FunctionUnit; shared TOML config utilities (toml_merge deep-merge, list_override DSL) code-ranker-plugins/ # Rust — all language plugins: languages/{rust,python,javascript,typescript,go,c,cpp,csharp,markdown} (+ shared languages/ecmascript & languages/cfamily) over one generic engine/ (tree-sitter metric walker); src/config/ (parse/views/specs/lookup facade) + src/defaults.toml; #[cfg(test)] test_support helpers code-ranker-viewer/ # Rust — HTML viewer: assets + render_html_viewer code-ranker-cli/ # Rust — orchestrator, plugin dispatch (over the plugin-api self-registered registry), check linter, report src/ plugin/ # Built-in plugins: rust.rs (incl. module→file collapse), python.rs, javascript.rs, finalize.rs (file-graph normalizer for Python/JS), mod.rs - presets.rs # Generic Prompt-Generator preset catalog (principles) + principles.rs # Generic Prompt-Generator principle catalog (principles) recommend.rs # Recommendation engine: scorecard + prompt formats (CLI counterpart of the viewer's Prompt Generator) assets/ # HTML/CSS/JS assets embedded via include_str! (see code-ranker-viewer/DESIGN.md for the full layer breakdown) index.html # Shell template (single Files view); cs-baseline / cs-current JSON script tags embedded inline at render time @@ -1271,7 +1271,7 @@ code-ranker/ typescript/ # TypeScript/JavaScript principle docs ``` -A preset's `doc_url` resolves to `languages/<doc_lang>/<id>.md` for the principles +A principle's `doc_url` resolves to `languages/<doc_lang>/<id>.md` for the principles a language overrides (its `doc_overrides`), and to `languages/base/<id>.md` otherwise — so a language without its own corpus inherits `base/`. diff --git a/docs/PRD.md b/docs/PRD.md index 7e882dc6..31df1918 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -324,7 +324,7 @@ Top-level fields: `edges`, `cycles`, `stats`, and a computed `ui` block (column/sort/size order and an optional `grouping` telling the viewer how to cluster nodes — e.g. `{ "key": "crate" }`) -- `presets` — the Prompt-Generator principle catalog (`id` / `title` / `prompt` / +- `principles` — the Prompt-Generator principle catalog (`id` / `title` / `prompt` / `sort_metric` / `connections` / …), language-adapted; omitted when empty - `prompt` — the language-neutral Prompt-Generator **scaffolding** (`intro` / `doc_note` / `task` / `focus` / `cycle_note`), so the CLI `prompt` format and @@ -452,7 +452,7 @@ workspaces. The plugin MUST: and counted in `fan_out_external` instead. The advisory scorecard / viewer / prompt tiers are derived from the project's own `[rules.thresholds.file]` gate (not language-calibrated), so the report shows exactly what fails `check`. - The recommendation catalog is the shared 13 design-principle presets (from + The recommendation catalog is the shared 13 design principles (from `defaults.toml`); each coupling/complexity **metric** carries its own fix-prompt doc under `languages/base/` (`HK`, `Fan-in`, `Fan-out`, `Cognitive`, `Cyclomatic`; the cycle metric reuses `ADP`), referenced from the metric's `remediation` @@ -751,7 +751,7 @@ can render any language/metric set without hardcoding names. "nodes": [...], "edges": [...], "cycles": [...], "stats": { ... } } }, - "presets": [ { "id": "ADP", "label": "ADP", "title": "…", "prompt": "…", + "principles": [ { "id": "ADP", "label": "ADP", "title": "…", "prompt": "…", "doc_url": "…", "sort_metric": "cycle", "connections": ["common","out"] } ] } ``` @@ -761,7 +761,7 @@ The dictionaries are pruned to the keys/kinds/groups actually present at that level, and the `ui` block is computed by the orchestrator from the present attributes. Every metric's label / name / formula / live-`calc` / direction / threshold lives in `node_attributes`, and the Prompt-Generator principles live in -top-level `presets`, so the **viewer hardcodes no metric, kind, threshold or +top-level `principles`, so the **viewer hardcodes no metric, kind, threshold or prompt by name** — it renders entirely from this data (see DESIGN §3.2 HTML assets). Optional `AttributeSpec` fields are omitted when absent. @@ -773,7 +773,7 @@ graph-scope `agg(…)` aggregates emitted into `stats`) with no code change; onl tier-1 counting and the graph algorithms (`fan_in`/`fan_out`/`cycle`) are in Rust. A project config can also surface those metrics in the report (`[report]` column / card / stats list-overrides), gate `check` on them (`[rules.thresholds.file]`, -custom metrics included), and add Prompt-Generator lenses (`[presets.<ID>]`) — all +custom metrics included), and add Prompt-Generator lenses (`[principles.<ID>]`) — all data, no code. See `docs/code-ranker-cli/config.md` and `docs/customization/`. **Node shape** — `id`, `kind`, `name`, optional `parent`, plus flat attributes: diff --git a/docs/ai-skill.md b/docs/ai-skill.md index e9e92a8c..6439f10d 100644 --- a/docs/ai-skill.md +++ b/docs/ai-skill.md @@ -41,7 +41,7 @@ Focus on these; treat everything else as secondary. `code-ranker report --doc HK` (prints the full principle to the terminal, offline). **Strategy:** fix one thing at a time, worst-first. Cycles (ADP) are structural — -clear them first; then coupling (HK). Focus an axis with `--focus-rule` and inspect +clear them first; then coupling (HK). Focus on one metric or principle with `--focus` and inspect the worst tier with `--severity warning`. ## The fix loop @@ -51,13 +51,13 @@ One thing per pass, worst-first. ```sh # 1. Find what to fix. The gate verdict: code-ranker check . -# …or focus one axis in the triage (cycle = ADP, then hk, sloc, cognitive, …): -code-ranker report . --output.scorecard --focus-rule cycle --top 1 +# …or focus one metric or principle in the triage (cycle = ADP, then hk, sloc, cognitive, …): +code-ranker report . --output.scorecard --focus cycle --top 1 # 2. Get the actionable fix-prompt for the single worst module (auto-targeted): code-ranker report . --output.prompt.path=stdout --top 1 # …or get a focused fix-prompt directly (metric- or principle-framed): -code-ranker report . --output.prompt.path=stdout --focus-rule hk --top 1 +code-ranker report . --output.prompt.path=stdout --focus hk --top 1 # 3. Review it; propose the fix to the user and get agreement. @@ -79,22 +79,22 @@ open .code-ranker/after.html # macOS; xdg-open on Linux Notes: -- `--output.prompt` **requires `--top 1`**. Without `--focus-rule` it is **auto-targeted** - at the single worst module of the worst-violating principle; add `--focus-rule` to pick - the axis yourself (see the next note). -- To focus a specific axis, narrow the triage with `--output.scorecard --focus-rule <name>`: +- `--output.prompt` **requires `--top 1`**. Without `--focus` it is **auto-targeted** + at the single worst module of the worst-violating principle; add `--focus` to pick + the focus yourself (see the next note). +- To focus a specific metric or principle, narrow the triage with `--output.scorecard --focus <name>`: a **metric** (`cycle`, `hk`, `sloc`, `cognitive`, `cyclomatic`, `fan_in`, `fan_out`, `items` — also accepts the full rule id, e.g. `threshold.file.hk`) or a **principle** id - (`LSP`, `SRP`, `OCP`, …). `--focus-rule` also applies to - `--output.prompt`: `--focus-rule hk --output.prompt.path=stdout --top 1` emits a + (`LSP`, `SRP`, `OCP`, …). `--focus` also applies to + `--output.prompt`: `--focus hk --output.prompt.path=stdout --top 1` emits a **metric-framed** fix-prompt directly (titled "HK — Henry–Kafura", no Liskov wrapper), - while `--focus-rule <PRINCIPLE>` emits a **principle-framed** one. Without `--focus-rule`, the + while `--focus <PRINCIPLE>` emits a **principle-framed** one. Without `--focus`, the prompt auto-targets the worst-violating principle. - To scope the ranking to a subtree, add `--focus-path <dir>` (repeatable): the whole project is still analyzed, but only modules under those repo-relative paths are - ranked/listed (a folder matches everything beneath it). Combine with `--focus-rule` to + ranked/listed (a folder matches everything beneath it). Combine with `--focus` to intersect; cycles stay global (they are not narrowed by `--focus-path`). -- For `--focus-rule cycle`, `--top 1` shows **one whole cycle** — the biggest `chain` +- For `--focus cycle`, `--top 1` shows **one whole cycle** — the biggest `chain` (else the biggest `mutual`) — with **all** its modules listed, so you can fix the loop as a unit. @@ -118,7 +118,7 @@ threshold mismatch. ```sh code-ranker report . --output.scorecard # triage: all principles -code-ranker report . --output.scorecard --focus-rule hk --top 1 # focus one axis +code-ranker report . --output.scorecard --focus hk --top 1 # focus one metric or principle code-ranker report . --output.prompt.path=stdout --top 1 # LLM fix-prompt for the worst module code-ranker check . --baseline base.json --output-format json # CI regression verdict ``` @@ -127,7 +127,7 @@ code-ranker check . --baseline base.json --output-format json # CI regression - Analysis is offline and fast. The Rust plugin needs a warm cargo cache (`cargo metadata --offline`); if it errors, run `cargo fetch` first. -- `--focus-rule` / `--focus-path` / `--severity` / `--top` are **report-only** — they +- `--focus` / `--focus-path` / `--severity` / `--top` are **report-only** — they require a `--output.prompt` or `--output.scorecard`, else the run errors. - `--output.prompt` **requires `--top 1`** — it is auto-targeted at the single worst module. For a broader view use `--output.scorecard`. diff --git a/docs/code-ranker-cli/CLI.md b/docs/code-ranker-cli/CLI.md index d4c047b1..76f0c0e7 100644 --- a/docs/code-ranker-cli/CLI.md +++ b/docs/code-ranker-cli/CLI.md @@ -153,7 +153,7 @@ code-ranker check [input] [options] | `--cycle-rule <KIND=on\|off\|N>` | Configure a cycle check. KIND: `mutual`, `chain`. Value: `on` (any cycle fails), `off` (ignored), or `N` (allow up to N cycles of that kind — e.g. `chain=7` forbids an 8th). Defaults: `mutual`/`chain` on. | | `--baseline <snapshot>` | Compare `[input]` (current) against this baseline snapshot (`.json` or `.html`) and switch to a **relative gate**: fail only on *new* violations vs the baseline; pre-existing ones are tolerated. See [`--baseline`](#--baseline-comparison). | | `--focus-path <path>` | Restrict the gate to these files/folders. The whole project is still analyzed (the dependency graph needs it), but a violation outside the focused paths is dropped — neither reported nor counted toward the exit code. A folder matches everything beneath it. Repeatable. See [`--focus`](#--focus-scoping). | -| `--focus-rule <rule\|group>` | Restrict the gate to these rules / concern groups. Matches a full rule id (`threshold.file.hk`, `check.inline_tests_too_large`), the bare id (`inline_tests_too_large`), or a group (`TST`, `CPL`). Repeatable; combine with `--focus-path` to intersect (a violation must match both). See [`--focus`](#--focus-scoping). | +| `--focus <rule\|group>` | Restrict the gate to these rules / concern groups. Matches a full rule id (`threshold.file.hk`, `check.inline_tests_too_large`), the bare id (`inline_tests_too_large`), or a group (`TST`, `CPL`). Repeatable; combine with `--focus-path` to intersect (a violation must match both). See [`--focus`](#--focus-scoping). | | `--output-format <fmt>` | Diagnostics format: `human` (default), `json`, `github`, `sarif`, `codequality`, `prompt`. Use `github` for GitHub PR annotations, `sarif` for GitHub code scanning / GitLab ≥18.11, `codequality` for the GitLab Code Quality MR widget, `json` for generic tooling, `prompt` for a Markdown AI fix-prompt built from the gate's own violations — one command both gates and (on failure) prints the prompt, tied exactly to what failed. | | `--top <N>` | Report only the `N` worst violations (ranked worst-first) and suppress the rest. A reporting limit only — it does **not** change the exit code. Default: all. | | `--exit-zero` | Return exit code 0 even when violations exist. Useful in non-blocking CI checks. | @@ -182,7 +182,7 @@ violations. exactly or, treated as a folder, anything beneath it; a leading `./` and a trailing `/` are ignored. Locationless violations (e.g. a cycle whose breaking edge can't be placed) can't be attributed to a path and are dropped. -- **`--focus-rule <rule|group>`** — keep violations of a rule or concern group. Matches a +- **`--focus <rule|group>`** — keep violations of a rule or concern group. Matches a full rule id (`threshold.file.hk`, `check.inline_tests_too_large`), the bare id (`inline_tests_too_large`), or a group (`TST`, `CPL`). @@ -194,11 +194,11 @@ Both are repeatable. With both set they **intersect** — a violation must match code-ranker check . --focus-path crates/code-ranker-plugin-api/src/plugin.rs # list only one custom linter's hits (rule id, bare id, or its group all work) -code-ranker check . --focus-rule check.inline_tests_too_large -code-ranker check . --focus-rule TST +code-ranker check . --focus check.inline_tests_too_large +code-ranker check . --focus TST # intersect: that rule, but only under one folder -code-ranker check . --focus-path crates/code-ranker-graph --focus-rule TST +code-ranker check . --focus-path crates/code-ranker-graph --focus TST ``` ```sh @@ -287,16 +287,16 @@ code-ranker report [input] [options] |---|---|---| | `--output.<fmt>.path <path>` | `json` + `html` in `.code-ranker/` | Which artifacts to emit and where. `<fmt>` is `json`, `html`, `prompt`, or `scorecard`. Repeatable, one per format. See [Output paths](#output-paths). | | `--baseline <snapshot>` | — | Baseline snapshot (`.json` or `.html`). Turns the HTML into a diff (baseline vs current) with a verdict, and names it `…-diff.html`. See [`--baseline`](#--baseline-comparison). | -| `--focus-rule <NAME>` | auto (all principles) | Frame the output by a **metric** (`hk`, `cycle`, `sloc`, `cognitive`, `cyclomatic`, `fan_in`, `fan_out`, `items` — case-insensitive; also accepts the full threshold rule id `threshold.file.hk`, matched **by value** so it works whether or not the metric has a configured threshold) or a **principle** id (`LSP`, `ADP`, `SRP`, `OCP`, `DIP`, `ISP`, `DRY`, `KISS`, `LoD`, `MISU`, `CoI`, `YAGNI`, `CPX`). A metric narrows the `scorecard` to that axis and emits a metric-framed `prompt`; a principle emits a principle-framed `prompt`. Without it the scorecard spans every principle and the prompt auto-targets the worst. Applies to both `scorecard` and `prompt`. Unknown names error with both namespaces listed. See [Recommendations](#recommendations-scorecard--prompt). | -| `--focus-path <PATH>` | all modules | Restrict the ranked modules to a subtree. The whole project is still analyzed (the dependency graph needs it), but only modules under one of these repo-relative paths are ranked/listed; a folder matches everything beneath it. Repeatable; combine with `--focus-rule` to intersect. A dependency cycle is a global unit, so `--focus-path` does **not** narrow cycle members — only the node-ranked metric/breach lists. See [Recommendations](#recommendations-scorecard--prompt). | +| `--focus <NAME>` | auto (all principles) | Frame the output by a **metric** (`hk`, `cycle`, `sloc`, `cognitive`, `cyclomatic`, `fan_in`, `fan_out`, `items` — case-insensitive; also accepts the full threshold rule id `threshold.file.hk`, matched **by value** so it works whether or not the metric has a configured threshold) or a **principle** id (`LSP`, `ADP`, `SRP`, `OCP`, `DIP`, `ISP`, `DRY`, `KISS`, `LoD`, `MISU`, `CoI`, `YAGNI`, `CPX`). A metric narrows the `scorecard` to that metric and emits a metric-framed `prompt`; a principle emits a principle-framed `prompt`. Without it the scorecard spans every principle and the prompt auto-targets the worst. Applies to both `scorecard` and `prompt`. Unknown names error with both namespaces listed. See [Recommendations](#recommendations-scorecard--prompt). | +| `--focus-path <PATH>` | all modules | Restrict the ranked modules to a subtree. The whole project is still analyzed (the dependency graph needs it), but only modules under one of these repo-relative paths are ranked/listed; a folder matches everything beneath it. Repeatable; combine with `--focus` to intersect. A dependency cycle is a global unit, so `--focus-path` does **not** narrow cycle members — only the node-ranked metric/breach lists. See [Recommendations](#recommendations-scorecard--prompt). | | `--severity <tier>` | all tiers | Threshold tier for the `scorecard`: `info`, `warning`, or `auto`. Repeatable to show several tiers. | -| `--top <N>` | 15 (scorecard) | `scorecard`: how many rows; `--top 1` = the single worst module. With `--focus-rule cycle`, `--top 1` prints one entire cycle (biggest `chain` first) with **all** its members. `prompt`: **must be `--top 1`** — the prompt is auto-targeted at the single worst module. | +| `--top <N>` | 15 (scorecard) | `scorecard`: how many rows; `--top 1` = the single worst module. With `--focus cycle`, `--top 1` prints one entire cycle (biggest `chain` first) with **all** its members. `prompt`: **must be `--top 1`** — the prompt is auto-targeted at the single worst module. | | `--export-full-config <PATH>` | — | Instead of analyzing, write the **full effective configuration** to `PATH` and exit. See [Inspecting the effective config](#inspecting-the-effective-config). | -`--focus-rule`, `--focus-path`, `--severity`, and `--top` apply only when a `prompt` or +`--focus`, `--focus-path`, `--severity`, and `--top` apply only when a `prompt` or `scorecard` format is selected; passing them otherwise is an error. `--output.prompt` additionally **requires `--top 1`** (it is auto-targeted at the single worst module); -`--severity` is `scorecard`-only, while `--focus-rule` now drives **both** the `scorecard` +`--severity` is `scorecard`-only, while `--focus` now drives **both** the `scorecard` and the `prompt`. ### Inspecting the effective config @@ -309,7 +309,7 @@ no analysis runs — as one TOML document with two top-level sections: every effective `ignore` / `rules` / `output` / `levels` value, including the ones you did not set (inherited from the defaults). - `[plugin]` — the active plugin's fully-merged language config (its inheritance chain - `defaults.toml ⊕ [base] ⊕ <lang>.toml`): presets, node/edge + `defaults.toml ⊕ [base] ⊕ <lang>.toml`): principles, node/edge kinds, the metric-engine role tables, etc. It honours `--plugin` and `--config`, so you can preview any combination: @@ -318,12 +318,12 @@ It honours `--plugin` and `--config`, so you can preview any combination: # what `report` would use here, with my overrides folded in code-ranker report . --config ci/strict.toml --export-full-config /tmp/full.toml -# the full Python plugin config (presets, vocab) +# the full Python plugin config (principles, vocab) code-ranker report . --plugin python --export-full-config /tmp/python.toml ``` It is a **diagnostic view** of every parameter you can override — because the two -sections use different schemas (and `presets` differs between the project and plugin +sections use different schemas (and `principles` differs between the project and plugin shapes), the file is not meant to be fed back as a single `--config`. ```sh @@ -342,8 +342,8 @@ code-ranker report . --baseline .code-ranker/main.json --output.html.path=diff.h # console triage overview — what to fix first code-ranker report . --output.scorecard -# narrow the triage to one axis (coupling) -code-ranker report . --output.scorecard --focus-rule hk --top 5 +# narrow the triage to one metric (coupling) +code-ranker report . --output.scorecard --focus hk --top 5 # AI fix-prompt for the single worst module (auto-targeted), to stdout code-ranker report . --output.prompt.path=stdout --top 1 @@ -399,7 +399,7 @@ When selected, `sarif` defaults to `.code-ranker/{ts}-{git-hash-3}.sarif` and The recommendation formats have their own per-format defaults: `scorecard` defaults to **`stdout`** (it is a console overview), and `prompt` defaults to the file -`.code-ranker/{ts}-{git-hash-3}-{preset}.md`. +`.code-ranker/{ts}-{git-hash-3}-{principle}.md`. To pin destinations project-wide instead of passing flags every time, set them in config: @@ -428,7 +428,7 @@ path = "dist/{project-dir}-{ts}.codequality.json" | `{ts}` | The run's `generated_at` as a local timestamp, `YYYYMMDD-HHMMSS`. One value per run, shared by every artifact. | `20260526-114144` | | `{git-hash}` | The 12-char short commit hash (zeros if not a git repo). | `a3f9c21b4d5e` | | `{git-hash-N}` | The first `N` chars of the commit hash. | `{git-hash-3}` → `a3f` | -| `{preset}` | The principle id of the auto-targeted prompt (`prompt` only). | `SRP` | +| `{principle}` | The principle id of the auto-targeted prompt (`prompt` only). | `SRP` | So the default `{ts}-{git-hash-3}.json` yields `20260526-114144-a3f.json`. When `[input]` is a **snapshot**, `{git-hash}` / `{ts}` are read from the snapshot's embedded metadata — @@ -446,9 +446,9 @@ refactoring guidance: - **`prompt`** — a ready-to-paste AI fix-prompt, **auto-targeted at the single worst module** (the same Markdown the HTML viewer's Prompt Generator produces). -Both rank modules with the same engine. The `scorecard` is steered by `--focus-rule` -(narrow to one axis), `--focus-path` (scope to a subtree), `--severity` (which tier), and -`--top` (how many rows). The `prompt` also honours `--focus-rule` (frame it by a metric or +Both rank modules with the same engine. The `scorecard` is steered by `--focus` +(narrow to one metric or principle), `--focus-path` (scope to a subtree), `--severity` (which tier), and +`--top` (how many rows). The `prompt` also honours `--focus` (frame it by a metric or a principle); without it the prompt auto-targets the single worst module. Both **require `--top 1`** for the `prompt`. @@ -479,9 +479,9 @@ show several tiers at once; with none given it shows all tiers. Cycle-based principles (e.g. `ADP`) have **no numeric threshold** — every module in a dependency cycle counts, ranked by HK, and `--severity` is ignored for them. -### Focus (`--focus-rule` / `--focus-path`) +### Focus (`--focus` / `--focus-path`) -`--focus-rule <NAME>` frames the output, resolving NAME (case-insensitive) against **two +`--focus <NAME>` frames the output, resolving NAME (case-insensitive) against **two namespaces**: - a **metric** — the bare key `hk` (Henry-Kafura coupling), `cycle` (dependency cycles — @@ -489,21 +489,21 @@ namespaces**: `fan_out` (coupling direction), `items` (interface size), **or** the full threshold rule id (`threshold.file.hk`). Matched **by value**, so it works whether or not the metric has a configured `[rules.thresholds.file]` threshold. This narrows the `scorecard` - to that axis and frames the `prompt` by the **metric itself** — its own name, + to that metric and frames the `prompt` by the **metric itself** — its own name, description, and `remediation` doc (e.g. `languages/base/HK.md`), with **no** SOLID design-principle wrapper. - a **principle** id — `LSP`, `ADP`, `SRP`, `OCP`, `DIP`, `ISP`, `DRY`, `KISS`, `LoD`, `MISU`, `CoI`, `YAGNI`, `CPX`. This frames the output by that **design principle** (the prior behaviour). -An unknown name is a hard error that lists both namespaces (`unknown --focus-rule '<name>'. +An unknown name is a hard error that lists both namespaces (`unknown --focus '<name>'. Metrics: …. Principles: …`). -`--focus-rule` drives **both** outputs. `--focus-rule hk --output.prompt.path=stdout --top 1` +`--focus` drives **both** outputs. `--focus hk --output.prompt.path=stdout --top 1` emits an **HK-framed** fix-prompt directly (titled "HK — Henry–Kafura", no Liskov wrapper); -`--focus-rule LSP …` emits the **Liskov-framed** prompt. Without `--focus-rule` the scorecard +`--focus LSP …` emits the **Liskov-framed** prompt. Without `--focus` the scorecard spans all principles (one row each) and the `prompt` auto-targets the single worst module's -principle. The principle *catalog* lives in the snapshot's `presets` (shared with the HTML +principle. The principle *catalog* lives in the snapshot's `principles` (shared with the HTML viewer's Prompt Generator and used for the prompt's prose). `cycle` has **no numeric threshold** — every module in a dependency cycle counts, ranked by HK, and `--severity` is ignored for it. @@ -511,7 +511,7 @@ ignored for it. `--focus-path <PATH>` restricts the ranked modules to a subtree (repeatable). The whole project is still analyzed (the graph needs it), but only modules under one of these repo-relative paths are ranked/listed; a folder matches everything beneath it. Combine with -`--focus-rule` to intersect. A dependency cycle is a global unit, so `--focus-path` does +`--focus` to intersect. A dependency cycle is a global unit, so `--focus-path` does **not** narrow cycle members — only the node-ranked metric/breach lists. ### `scorecard` — triage overview @@ -524,7 +524,7 @@ modules overall: code-ranker report . --output.scorecard # all tiers, ~15 rows code-ranker report . --output.scorecard --severity warning --top 20 code-ranker report . --output.scorecard.path=triage.txt # to a file instead -code-ranker report . --output.scorecard --focus-rule sloc # narrow to one axis +code-ranker report . --output.scorecard --focus sloc # narrow to one metric ``` ```text @@ -543,19 +543,19 @@ WORST MODULES → code-ranker report . --output.prompt.path=… --top 1 ``` -`--top N` caps the worst-modules list (default ~15); `--focus-rule <NAME>` narrows the -scorecard to a single ranking axis (or frames it by a principle); `--focus-path <PATH>` +`--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. ### `prompt` — AI fix-prompt for the worst module -Defaults to the file `.code-ranker/{ts}-{git-hash-3}-{preset}.md` (use +Defaults to the file `.code-ranker/{ts}-{git-hash-3}-{principle}.md` (use `--output.prompt.path=stdout` to pipe it). It is **auto-targeted**: it emits the Markdown fix-prompt for the **single worst module** — its principle's intent and summary, how to read the full principle (the offline `code-ranker report --doc <id>` command, no network), a task checklist, the offending module annotated with its metric value, and the relevant **flow** connection lists (`uses` — structural `contains`/`reexports` are excluded). The -`{preset}` in the default filename is the auto-selected principle id. +`{principle}` in the default filename is the auto-selected principle id. It **requires `--top 1`** (prompts are long, and the prompt always describes exactly one module). There is no principle selection and no `--index`. @@ -577,9 +577,18 @@ resolved `languages/<lang>/<ID>.md`, with any `[templates.languages.…]` overri no network. Both accept a principle id (`SRP`, `ADP`) or a metric key (`hk`, `cyclomatic`), case-insensitive; they are mutually exclusive and write no artifacts. +`--doc` resolves an id, in order: a principle id, a metric key (the canonical doc +filename comes from the metric's `remediation`, e.g. key `fan_in` → `Fan-in.md`), +then **any base doc by its filename stem** — so `--doc Fan-in`, `--doc metrics` +(the LOC-counting reference) and `--doc AI` all work. `--doc cycle` resolves to the +ADP doc (cycle is ADP's metric lens). **`--doc AI`** prints an AI-agent overview: a +short playbook plus a catalog of every principle/metric with its one-paragraph +TL;DR (auto-assembled from each base doc — see `templates.rs::tldr_index`). + ```sh code-ranker report . --prompt HK --top 1 # HK fix-prompt for the worst module code-ranker report . --doc HK # the full HK principle text, to stdout +code-ranker report . --doc AI # AI playbook + the principle/metric catalog ``` ## `--baseline` (comparison) @@ -641,7 +650,7 @@ when `[input]` is a directory): The HTML report is **self-contained**: the viewer app (Dagre graph layout, pan/zoom, a sortable node table for the single Files view, and the prompt-generator panel whose -preset buttons are read from `snapshot.presets` — the 13 design principles ADP / SRP / +principle buttons are read from `snapshot.principles` — the 13 design principles ADP / SRP / OCP / LSP / ISP / DIP / DRY / KISS / LoD / MISU / CoI / YAGNI / CPX) **and the snapshot data** are all embedded in the one file. External library nodes render in a distinct amber colour with dashed diff --git a/docs/code-ranker-cli/DESIGN.md b/docs/code-ranker-cli/DESIGN.md index 9c460bd2..e479982c 100644 --- a/docs/code-ranker-cli/DESIGN.md +++ b/docs/code-ranker-cli/DESIGN.md @@ -154,7 +154,7 @@ the snapshot's `graphs` map under `"files"`. to write to the stdout stream (`is_stream` / `write_artifact`). The JSON snapshot records `config_file` when a config was found. Names are templates (`render_name`) with placeholders `{project-dir}`, `{ts}`, `{git-hash}` - (12-char short commit) and `{git-hash-N}` (first N chars) — plus `{preset}` + (12-char short commit) and `{git-hash-N}` (first N chars) — plus `{principle}` for the recommendation formats. `{ts}` is the snapshot's `generated_at` formatted as a local timestamp — read once, not a fresh clock call per file, so every artifact of a run shares one stamp that matches the embedded @@ -164,7 +164,7 @@ the snapshot's `graphs` map under `"files"`. (`DEFAULT_JSON_PATH` / `DEFAULT_HTML_PATH` / `DEFAULT_SARIF_PATH` / `DEFAULT_CODEQUALITY_PATH` = `.code-ranker/{ts}-{git-hash-3}.{json,html,sarif,codequality.json}`; - `DEFAULT_PROMPT_PATH` = `.code-ranker/{ts}-{git-hash-3}-{preset}.md`; + `DEFAULT_PROMPT_PATH` = `.code-ranker/{ts}-{git-hash-3}-{principle}.md`; `DEFAULT_SCORECARD_PATH` = `stdout`). The HTML viewer template and all assets (CSS, JS) are embedded in the binary via `include_str!` from `crates/code-ranker-viewer/src/assets/`, and the snapshot @@ -182,7 +182,7 @@ the snapshot's `graphs` map under `"files"`. (`write_recommendations` → the `recommend` module, the console counterpart of the viewer's Prompt Generator): `prompt` emits the LLM Markdown for one principle, `scorecard` a console triage table. The `scorecard` is narrowed by - `--focus-rule` (one ranking axis, a full threshold rule id, or a principle), + `--focus` (one metric or principle), `--focus-path` (scope the ranked modules to a subtree) and `--severity` (`info` / `warning` / `auto`; repeatable) and capped by `--top`. The `prompt` is **auto-targeted at the single worst module** and requires `--top 1` — there is no CLI principle selector. These @@ -201,27 +201,27 @@ dispatch, and artifact I/O routing. `crates/code-ranker-cli/src/recommend.rs` is the console counterpart of the HTML viewer's Prompt Generator (`export-popup.js`) — it derives refactoring guidance from the snapshot's gate-derived `node_attributes[*].thresholds`. It is pure -(reads a `LevelGraph` + `presets`, no I/O) and language-agnostic (it hardcodes no -metric — it reads each preset's `sort_metric` and the metric's thresholds from +(reads a `LevelGraph` + `principles`, no I/O) and language-agnostic (it hardcodes no +metric — it reads each principle's `sort_metric` and the metric's thresholds from the snapshot). Functions: - `reco_for(level, metric) -> Reco` — the file nodes ranked worst-first (tie-broken `sloc` → `items`) plus the `warning` / `info` breach counts; mirrors the viewer's `recoFor`. The pseudo-metric `"cycle"` ranks the cycle members (by HK) and both counts equal that set's size. -- `worst_preset(level, presets)` — the principle with the most violations +- `worst_principle(level, principles)` — the principle with the most violations (`warning` count, tie-broken by `info`, then catalog order), used to auto-target the `prompt` (which has no CLI principle selector) at the worst hotspot. -- `compose_prompt(level, presets, preset_id, severity, top)` — the same Markdown +- `compose_prompt(level, principles, principle_id, severity, top)` — the same Markdown the viewer emits (`composePrompt` + `buildContent`): intent + summary + principle-doc link + task checklist, then the ranked offending modules, then - the preset's connection lists (`common` / `in` / `out`, only those with edges). -- `render_scorecard(plugin, level, presets, severities, top, narrow)` — the + the principle's connection lists (`common` / `in` / `out`, only those with edges). +- `render_scorecard(plugin, level, principles, severities, top, narrow)` — the console triage: a per-principle table (`warning` / `info` counts + worst module) and the worst modules overall (`node_breaches` ranks by selected-tier breach count, then HK), with a next-step hint to the worst principle. -`run_report`'s `write_recommendations` resolves the preset/severity/top, then +`run_report`'s `write_recommendations` resolves the principle/severity/top, then calls these. All of it is **advisory** — it never affects an exit code (that is `check`'s job). diff --git a/docs/code-ranker-cli/PRD.md b/docs/code-ranker-cli/PRD.md index e07cf45a..1a8a9eab 100644 --- a/docs/code-ranker-cli/PRD.md +++ b/docs/code-ranker-cli/PRD.md @@ -77,7 +77,7 @@ named (`sarif` default `{ts}-{git-hash-3}.sarif`, `codequality` default listed formats are written. The `.path` value is a file path (or a name template, or `stdout`/`-` to stream the artifact); it supports placeholders `{project-dir}` (slugified workspace name), `{ts}`, `{git-hash}` (the -12-char short commit), `{git-hash-N}` (its first N chars), and `{preset}` (the +12-char short commit), `{git-hash-N}` (its first N chars), and `{principle}` (the active principle id, `prompt` / `scorecard` only). The destination resolves as **`--output.<fmt>.path` flag › `[output.<fmt>] path` in `code-ranker.toml` › built-in default**, so a project can pin its @@ -85,7 +85,7 @@ own naming while a flag still wins for named states (e.g., `pr.json`). With `--baseline`, the HTML default gains a `-diff` marker (`{ts}-{git-hash-3}-diff.html`); the JSON artifact is always the current snapshot, never a diff. The `scorecard` default is `stdout` and the `prompt` -default is `.code-ranker/{ts}-{git-hash-3}-{preset}.md`. No additional registry +default is `.code-ranker/{ts}-{git-hash-3}-{principle}.md`. No additional registry is created. Each snapshot is a **single self-contained `.json` file** combining @@ -347,7 +347,7 @@ code-ranker check [input] [--plugin <name|auto>] [--threshold ...] [--cycle-rul # Steps 1+2 — analyze (or read) the input and write a snapshot and/or HTML viewer # (also the AI prompt / console scorecard via --output.prompt / --output.scorecard) -code-ranker report [input] [--plugin <name|auto>] [--output.<fmt>.path <path>] [--baseline <snapshot>] [--focus-rule <NAME>] [--focus-path <PATH>] [--severity <tier>] [--top <N>] +code-ranker report [input] [--plugin <name|auto>] [--output.<fmt>.path <path>] [--baseline <snapshot>] [--focus <NAME>] [--focus-path <PATH>] [--severity <tier>] [--top <N>] ``` The positional `[input]` (default `.`) is polymorphic: a directory is diff --git a/docs/code-ranker-cli/USE-CASES.md b/docs/code-ranker-cli/USE-CASES.md index c17c7546..ac5099b4 100644 --- a/docs/code-ranker-cli/USE-CASES.md +++ b/docs/code-ranker-cli/USE-CASES.md @@ -17,7 +17,7 @@ Two commands underlie everything: `[input]` is polymorphic: a directory is analyzed; a `.json`/`.html` snapshot is read back with no re-analysis. -Ranking metrics used below (the `--focus-rule` axis that narrows the scorecard): `hk` +Ranking metrics used below (the `--focus` metric or principle that narrows the scorecard): `hk` (Henry-Kafura coupling), `cycle` (dependency cycles — the ADP view), `sloc` (module size), `cognitive` / `cyclomatic` (complexity), `fan_in` / `fan_out` (coupling direction), `items` (interface size). @@ -44,37 +44,37 @@ code-ranker report . --output.scorecard --top 5 **Triage one metric — Henry-Kafura coupling.** ```sh -code-ranker report . --output.scorecard --focus-rule hk +code-ranker report . --output.scorecard --focus hk ``` **Find the single worst HK module to fix first.** ```sh -code-ranker report . --output.scorecard --focus-rule hk --top 1 +code-ranker report . --output.scorecard --focus hk --top 1 ``` **Find the single worst dependency cycle.** ```sh -code-ranker report . --output.scorecard --focus-rule cycle --top 1 +code-ranker report . --output.scorecard --focus cycle --top 1 ``` **Triage the biggest files (module size).** ```sh -code-ranker report . --output.scorecard --focus-rule sloc --top 5 +code-ranker report . --output.scorecard --focus sloc --top 5 ``` **Triage the most cognitively complex files.** ```sh -code-ranker report . --output.scorecard --focus-rule cognitive --top 5 +code-ranker report . --output.scorecard --focus cognitive --top 5 ``` **Triage one subtree — scope the ranking to a folder.** ```sh -code-ranker report . --output.scorecard --focus-rule hk --focus-path crates/code-ranker-cli/src/ +code-ranker report . --output.scorecard --focus hk --focus-path crates/code-ranker-cli/src/ ``` **Show only warning-tier breaches (hide info-tier noise).** @@ -156,7 +156,7 @@ code-ranker check . --top 1 ## 4. Focused checks — gate a subset of files or rules > The whole project is always analyzed (the dependency graph needs it); `--focus-path` -> / `--focus-rule` only restrict what is reported and counted toward the exit code. +> / `--focus` only restrict what is reported and counted toward the exit code. **Gate only the file you are refactoring.** @@ -185,13 +185,13 @@ code-ranker check . $(git diff --name-only origin/main | sed 's/^/--focus-path / **List only one rule's / group's violations.** ```sh -code-ranker check . --focus-rule check.inline_tests_too_large # or: --focus-rule TST +code-ranker check . --focus check.inline_tests_too_large # or: --focus TST ``` **Intersect a rule with a folder.** ```sh -code-ranker check . --focus-path crates/code-ranker-graph --focus-rule TST +code-ranker check . --focus-path crates/code-ranker-graph --focus TST ``` **Combine a focused scope with a metric budget.** diff --git a/docs/code-ranker-cli/config.md b/docs/code-ranker-cli/config.md index 9f9f0103..c1295a98 100644 --- a/docs/code-ranker-cli/config.md +++ b/docs/code-ranker-cli/config.md @@ -64,7 +64,7 @@ The shared **metric catalog** (built-in derived metrics, aggregates, the default `[report]` columns/card/size/filter) is layered under the language `[report]`: [`crates/code-ranker-graph/metrics/builtin.toml`](https://github.com/ffedoroff/code-ranker/blob/main/crates/code-ranker-graph/metrics/builtin.toml). -**2. Project stack** — `[rules]`, `[metrics]`, `[presets]`, `[ignore]`, +**2. Project stack** — `[rules]`, `[metrics]`, `[principles]`, `[ignore]`, `[levels]`, output, and a project `[report]` patch. This is what `--config` and `code-ranker.toml` set: @@ -252,16 +252,16 @@ A node-scope metric is computed for every file (and function, when that level is on) and is usable as a `[rules.thresholds.file]` limit like any built-in (the key is validated at load — a typo, or a metric you never defined, is a hard error). -### `[presets.<ID>]` — project Prompt-Generator presets +### `[principles.<ID>]` — project Prompt-Generator principles -A preset is a refactoring lens: it ranks files by one metric and ships a +A principle ranks files by one metric and ships a ready-to-paste AI prompt, surfaced by the `scorecard` / `prompt` outputs and the -viewer's Prompt-Generator buttons. The plugin catalog ships the SOLID/complexity presets; +viewer's Prompt-Generator buttons. The plugin catalog ships the SOLID/complexity principles; a project adds its own (e.g. over a custom metric) here. The table key is the id; -a same-id project preset overrides the plugin's, a new id appends. +a same-id project principle overrides the plugin's, a new id appends. ```toml -[presets.TSR] +[principles.TSR] title = "TSR — Trim inline test bulk" # prompt heading (defaults to id) sort_metric = "tsr" # the metric the worst-first list ranks by prompt = "Move inline test modules into sibling test files…" @@ -269,7 +269,7 @@ prompt = "Move inline test modules into sibling test files…" ``` Only `sort_metric` is essential. See the worked example in -[`docs/customization/`](../customization/README.md#17-prompt-generator-presets--presetsid). +[`docs/customization/`](../customization/README.md#17-prompt-generator-principles--principlesid). ### `[output.json]` / `[output.html]` / `[output.sarif]` / `[output.codequality]` — report artifacts @@ -343,7 +343,7 @@ code-ranker check . --config ci/strict.toml A `report` flag: instead of analyzing, write the **full effective configuration** to `PATH` and exit. The file has two sections — `[project]` (built-in defaults ⊕ -your `--config`) and `[plugin]` (the `--plugin` language's merged config: presets, +your `--config`) and `[plugin]` (the `--plugin` language's merged config: principles, thresholds, vocab). A diagnostic view of every value you can override. ```bash diff --git a/docs/code-ranker-viewer/DESIGN.md b/docs/code-ranker-viewer/DESIGN.md index 9dc88d07..e824a3b9 100644 --- a/docs/code-ranker-viewer/DESIGN.md +++ b/docs/code-ranker-viewer/DESIGN.md @@ -34,7 +34,7 @@ the report is a single self-contained `.html`. > layer; every consumer reads from the snapshot dictionaries — flat node `attrs`, > `edge.source/target`, `node.cycle`, per-level `node_attributes` / `edge_kinds` > / `node_kinds` / `cycle_kinds` / `attribute_groups` / `ui`, and top-level -> `presets`. **No metric/kind/colour/threshold/prompt is hardcoded by name.** +> `principles`. **No metric/kind/colour/threshold/prompt is hardcoded by name.** > Metric formulas come from `AttributeSpec.formula`; the live derivation is > `eval`-ing `AttributeSpec.calc` over the node's attributes (`schema.js` > `calcDisplay`). Preview a real report with @@ -64,7 +64,7 @@ top-to-bottom). The viewer was split out of three former monoliths (`diagram.js` | File | Purpose | |------|---------| -| `schema.js` | The single data-access layer over the snapshot dictionaries (readers for `node_attributes` / `edge_kinds` / `node_kinds` / `cycle_kinds` / `attribute_groups` / `ui` / `presets` / `prompt` (the snapshot's Prompt-Generator scaffolding), plus `evalCalc`/`calcDisplay`). | +| `schema.js` | The single data-access layer over the snapshot dictionaries (readers for `node_attributes` / `edge_kinds` / `node_kinds` / `cycle_kinds` / `attribute_groups` / `ui` / `principles` / `prompt` (the snapshot's Prompt-Generator scaffolding), plus `evalCalc`/`calcDisplay`). | | `grouping.js` | The **grouping ladder** the reveal depth indexes into: `grouperForDig(level, dig)` / `groupKeyAtDig` (a tier ladder spanning synthetic crate-folders → crate tier → folders-under-crate → files, via `crateRoots`/`crateDirs`/`maxCrateDepth`; the reveal-depth lens indexes into it via `window.dig` (overview) / `window.drillDig` (drilled). `viewTier` picks the dimension (crate vs file); the **file tier** uses an absolute directory ladder anchored on `maxFileDepth`/`digFloor`, with `overviewBaseDig` the file-tier landing; `crateKeyToFileKey`/`fileKeyToCrateKey` map a focus key across dimensions for tier switching; one step past the deepest folder grouping is the **files level** — `maxUnderCrateDepth` (deepest folder nesting under a crate root) drives `filesDig`/`isFilesDig`, where `groupKeyAtDig` returns each node's id so every file is its own group, leaving the cluster wrapping to the renderer), plus `groupLabel` (box label — the **full** folder path: crate dir + absorbed source dir + folders when digging in, the collapsed crate-dir path when digging out), `groupCountAtDig` (group-box count at a dig level — powers the dig-control +/- previews), `nodeFullDir` (full workspace-relative dir of a node, e.g. `/crates/foo/src`), `focusDirPath`/`focusStripBase`/`stripDirPrefix` (the focused group's own dir, its **parent**, and the prefix-strip — drilled folder/sub-cluster labels read relative to the focus's parent so the focus folder keeps its name: `/src`, `/src/render`, not the long ancestor path), `crateRelDir` (crate-relative dir helper), and the small shared primitives `crateIdOf` (a node's crate attribute) / `nodeDirSegs` (a node's directory segments) reused across the viewer, `aggCycleStatus`, `clampDig`. Derives every tier from file-id paths + the crate attribute — no extra backend data. | | `diff.js` | Browser-side diff: `computeDiff()` (node/edge status), `computeCycles()` (reads cycle membership **solely** from the backend `graph.cycles`; derives per-side status + `edgeCycleStatus`), `computeMeta()`. | | `utils.js` | Shared formatting/escaping/DOM helpers (`fmtNum`, `fmtFull`, `fmtDate`, `escHtml`, …). | @@ -109,7 +109,7 @@ top-to-bottom). The viewer was split out of three former monoliths (`diagram.js` | File | Purpose | |------|---------| -| `export-popup.js` | `openExportPopup()` — the Prompt Generator: selected-vs-recommended source, per-metric two-tier threshold colouring, `snapshot.presets`-driven preset buttons, Markdown prompt composition (`composePrompt` — the scaffolding prose read from `snapshot.prompt`, the same source the CLI `prompt` format uses, so both render identically) rendered via snarkdown, full state mirrored to the URL. | +| `export-popup.js` | `openExportPopup()` — the Prompt Generator: selected-vs-recommended source, per-metric two-tier threshold colouring, `snapshot.principles`-driven principle buttons, Markdown prompt composition (`composePrompt` — the scaffolding prose read from `snapshot.prompt`, the same source the CLI `prompt` format uses, so both render identically) rendered via snarkdown, full state mirrored to the URL. | ### App shell diff --git a/docs/code-ranker-viewer/PRD.md b/docs/code-ranker-viewer/PRD.md index 348f3e94..47e603a6 100644 --- a/docs/code-ranker-viewer/PRD.md +++ b/docs/code-ranker-viewer/PRD.md @@ -136,13 +136,13 @@ output formats, so the guidance is reachable without opening the HTML Markdown the HTML Prompt Generator produces (intent, summary, principle-doc link, a task checklist, the ranked offending modules, and the principle's connection lists). Defaults to a per-principle file - `.code-ranker/{ts}-{git-hash-3}-{preset}.md` (or `stdout`). + `.code-ranker/{ts}-{git-hash-3}-{principle}.md` (or `stdout`). - `--output.scorecard[.path]` — a console **triage** overview (a per-principle table of `warning` / `info` counts + the worst module, then the worst modules overall, then a hint to the prompt for the worst principle). Defaults to `stdout`. -The `scorecard` is narrowed by `--focus-rule <NAME>` (one ranking axis: `hk`, `cycle`, +The `scorecard` is narrowed by `--focus <NAME>` (one ranking metric or principle: `hk`, `cycle`, `sloc`, `cognitive`, …; without it the table spans all principles) and `--severity <info|warning|auto>` (the tier; repeatable; `auto` = warning-if-any-else-info), and capped by `--top <N>`. The `prompt` is **auto-targeted at the single worst module** diff --git a/docs/customization/README.md b/docs/customization/README.md index 222035ee..fb0ae23a 100644 --- a/docs/customization/README.md +++ b/docs/customization/README.md @@ -13,8 +13,8 @@ There are two places config lives — know which one you want: | Layer | File | Who edits it | What it controls | |---|---|---|---| -| **Project** | `code-ranker.toml` (in your repo) | you, per project | custom `[metrics]`, `[rules]` thresholds/cycles/**checks**, `[report]` views, `[presets]`, `[ignore]`, `[levels]`, plugin/output | -| **Language** | `<lang>.toml` (shipped in the binary) | a language plugin author | the node-kind vocabulary, presets, and the **report list overrides** (`[report]`) | +| **Project** | `code-ranker.toml` (in your repo) | you, per project | custom `[metrics]`, `[rules]` thresholds/cycles/**checks**, `[report]` views, `[principles]`, `[ignore]`, `[levels]`, plugin/output | +| **Language** | `<lang>.toml` (shipped in the binary) | a language plugin author | the node-kind vocabulary, principles, and the **report list overrides** (`[report]`) | Custom metrics and thresholds are **project** config (runtime). The report list-override DSL is **language** config (compiled into the plugin). Both are @@ -160,7 +160,7 @@ card = { add = ["tsr"] } The full [`custom-field-example.toml`](./custom-field-example.toml) next to this doc also adds the tooltip / `formula_pretty` fields (§1.1), a `check` threshold on `tsr` (§1.4), and a -`TSR` Prompt-Generator preset (§1.7) — so the one file demonstrates every feature +`TSR` Prompt-Generator principle (§1.7) — so the one file demonstrates every feature in this guide. Run it. With no `--output.*.path`, artifacts land in the default `.code-ranker/` @@ -175,7 +175,7 @@ code-ranker check . --config docs/customization/custom-field-example.toml # triage scorecard ranked by the custom `tsr` metric (warning tier, worst file): code-ranker report . --config docs/customization/custom-field-example.toml \ - --output.scorecard --focus-rule tsr --severity warning --top 1 + --output.scorecard --focus tsr --severity warning --top 1 # or send the JSON to an explicit path / stdout: code-ranker report . --config docs/customization/custom-field-example.toml --output.json.path=- @@ -275,16 +275,16 @@ filter = { add = ["tsr_big"] } # SVG node filter (§3) Only keys that actually exist on a node survive (the orchestrator prunes the patched list), so listing a metric the current language doesn't emit is harmless. -### 1.7 Prompt-Generator presets — `[presets.<ID>]` +### 1.7 Prompt-Generator principles — `[principles.<ID>]` -A **preset** is a refactoring lens: it ranks files by one metric and ships a +A **principle** ranks files by one metric and ships a ready-to-paste AI prompt. The plugin catalog has the usual SOLID / complexity -presets; add your own (over a custom metric) with `[presets.<ID>]` — the table key -is the preset id. It feeds the `scorecard` (narrow to it with `--focus-rule`), the `prompt`, and the +principles; add your own (over a custom metric) with `[principles.<ID>]` — the table key +is the principle id. It feeds the `scorecard` (narrow to it with `--focus`), the `prompt`, and the viewer's Prompt-Generator buttons: ```toml -[presets.TSR] +[principles.TSR] title = "TSR — Trim inline test bulk" # heading of the generated prompt sort_metric = "tsr" # the metric the worst-first list ranks by prompt = """ @@ -294,13 +294,13 @@ modules into sibling test files, keeping coverage identical. # optional: label (button text, defaults to id), doc_url, connections = ["in","out","common"] ``` -Only `sort_metric` is essential (the lens the preset *is*); `label` / `title` -default to the id. A project preset with the **same id** as a plugin preset +Only `sort_metric` is essential (the metric the principle ranks by); `label` / `title` +default to the id. A project principle with the **same id** as a plugin principle overrides it; a new id appends. Run it: ```sh # scorecard narrowed to the metric, warning tier, worst file: -code-ranker report . --config code-ranker.toml --output.scorecard --focus-rule tsr --severity warning --top 1 +code-ranker report . --config code-ranker.toml --output.scorecard --focus tsr --severity warning --top 1 # or generate the auto-targeted refactoring prompt (single worst module): code-ranker report . --config code-ranker.toml --output.prompt --top 1 @@ -402,7 +402,7 @@ copy-pasteable into a project `code-ranker.toml` as-is. ## 2. Language config (`<lang>.toml`) — for plugin authors A language's `<lang>.toml` **inherits** the common `defaults.toml` and overrides -only the diffs (node-kind vocabulary, presets, default thresholds, …). A language +only the diffs (node-kind vocabulary, principles, default thresholds, …). A language that belongs to a **family** inherits an extra base layer in between — the chain is `defaults.toml ⊕ [base].toml ⊕ <lang>.toml` (via `config::load_chain`): JavaScript and TypeScript inherit `ecmascript/config.toml` (the shared engine vocab); C and C++ @@ -538,5 +538,5 @@ list patch key = { clear=true, remove=[..], replace={old="new"}, after={anchor=[..]}, before={anchor=[..]}, prepend=[..], add=[..] } report views [report] columns|card|stats = <list patch> (works in <lang>.toml AND code-ranker.toml) map controls [report] size|filter = <list patch> (SVG circle-size modes / node filters; built-ins sloc,hk / cycle) -preset [presets.ID] sort_metric="k" title="…" prompt="…" (scorecard --focus-rule k / prompt) +principle [principles.ID] sort_metric="k" title="…" prompt="…" (scorecard --focus k / prompt) ``` diff --git a/docs/customization/cel-reference.md b/docs/customization/cel-reference.md index adf62934..2582ba6c 100644 --- a/docs/customization/cel-reference.md +++ b/docs/customization/cel-reference.md @@ -367,4 +367,4 @@ group = "SRP" See [`README.md`](./README.md) for the full customization guide and [`custom-field-example.toml`](./custom-field-example.toml) for a runnable -metrics/threshold/preset example. +metrics/threshold/principle example. diff --git a/docs/customization/config-resolution.md b/docs/customization/config-resolution.md index bbf092d5..7fabe822 100644 --- a/docs/customization/config-resolution.md +++ b/docs/customization/config-resolution.md @@ -9,12 +9,12 @@ There are two independent resolution chains: - **Project config** — the `code-ranker.toml` that tunes *your* run (thresholds, cycles, custom metrics, checks, report views, output paths). Resolved at runtime. - **Language config** — the `<lang>.toml` that defines a *plugin's* vocabulary and - presets. Resolved at compile time, inside the binary. + principles. Resolved at compile time, inside the binary. Both layer with the **same deep-merge primitive** ([`code_ranker_plugin_api::toml_merge::deep_merge`](../../crates/code-ranker-plugin-api/src/toml_merge.rs)), so the per-key rules below are identical for both. For the merge mechanics -(table-vs-table recursion, `[[presets]]`-by-`id`, the list-op DSL) see +(table-vs-table recursion, `[[principles]]`-by-`id`, the list-op DSL) see [the merge semantics](#merge-semantics) section. For *what each key means*, see the [customization guide](README.md). @@ -130,7 +130,7 @@ Every CLI flag below overrides the corresponding TOML key for the current run. | `--git.branch` / `--git.commit` / `--git.dirty-files` / `--git.origin` | *(snapshot metadata — no TOML key)* | CI escape hatch; replaces what `git` would report | Flags with **no TOML equivalent** (they shape this run's output, not the config): -`--baseline`, `--focus-path`, `--focus-rule`, `--output-format`, `--top`, +`--baseline`, `--focus-path`, `--focus`, `--output-format`, `--top`, `--exit-zero`, `--suggest-config`, `--severity`, `--export-full-config`. Full flag reference: [CLI.md](../code-ranker-cli/CLI.md). @@ -184,7 +184,7 @@ TOML, deep-merged in this order (later overrides earlier), in - **`defaults.toml`** — [`crates/code-ranker-plugins/src/defaults.toml`](../../crates/code-ranker-plugins/src/defaults.toml). - The language-neutral base: the common `[[presets]]` catalog, `doc_base`, the + The language-neutral base: the common `[[principles]]` catalog, `doc_base`, the field-omission `[defaults]`, and the one-value-each `[ids]` / `[visibility]` / `[edges]` vocab every language shares. - **Family base (optional)** — a language in a family inherits one extra layer: @@ -192,15 +192,15 @@ TOML, deep-merged in this order (later overrides earlier), in `cfamily/config.toml`. So `js/config.toml` carries only what differs from the shared engine vocab. - **`<lang>.toml`** — the language's own node-kind tables (`[kinds]`, `[halstead]`, - `[loc]`), its `doc_lang` / `doc_overrides`, and any preset additions/overrides. + `[loc]`), its `doc_lang` / `doc_overrides`, and any principle additions/overrides. A standalone language passes `&[lang_toml]`; a family member passes -`&[base_lang_toml, lang_toml]`. The `[[presets]]` array merges **by `id`** (a -language preset replaces a same-`id` base preset, a new `id` appends) — see below. +`&[base_lang_toml, lang_toml]`. The `[[principles]]` array merges **by `id`** (a +language principle replaces a same-`id` base principle, a new `id` appends) — see below. ### Principle-doc resolution — the `base/` corpus fallback -A preset's `doc_url` inherits from a shared corpus the same way config inherits +A principle's `doc_url` inherits from a shared corpus the same way config inherits `defaults.toml`. It resolves (in [`specs.rs`](../../crates/code-ranker-plugins/src/config/specs.rs)) to `{doc_base}/{doc_lang}/{id}.md` for the ids a language **overrides**, and to `{doc_base}/base/{id}.md` otherwise — `base/` is the language-neutral fallback @@ -230,7 +230,7 @@ Both chains use the same `deep_merge(base, overlay)`. For each key of `overlay`: | Case | Result | |---|---| | table vs table | **recurse** — per-key deep merge | -| `[[presets]]` arrays | merge **by `id`**: same `id` replaces in place, new `id` appends | +| `[[principles]]` arrays | merge **by `id`**: same `id` replaces in place, new `id` appends | | array + op-table `{add,remove,replace,clear,prepend}` | inherited list **patched in place** (the [list-op DSL](README.md#21-the-list-override-dsl)) | | array + plain array | overlay **replaces** wholesale | | any scalar / type mismatch | overlay **replaces** the base value | @@ -239,7 +239,7 @@ Both chains use the same `deep_merge(base, overlay)`. For each key of `overlay`: So the default for a list is **replace**; opt into patching with an op-table. This is what lets a project's `--config strict.toml` add to a list set by `base.toml`, and a `<lang>.toml` extend (rather than restate) the inherited -preset catalog or edge-kind vocab. +principle catalog or edge-kind vocab. --- diff --git a/docs/customization/custom-field-example.toml b/docs/customization/custom-field-example.toml index e5c3a6ff..ddbac4fb 100644 --- a/docs/customization/custom-field-example.toml +++ b/docs/customization/custom-field-example.toml @@ -1,12 +1,12 @@ # Worked example for docs/customization/README.md — a TLOC/SLOC ratio metric, an # aggregate over only the large files (loc > 300), a `check` threshold on the -# custom metric, and a project-defined Prompt-Generator preset. Run from the repo +# custom metric, and a project-defined Prompt-Generator principle. Run from the repo # root (artifacts land in the git-ignored .code-ranker/ by default): # # code-ranker report . --config docs/customization/custom-field-example.toml --output.json --output.html # code-ranker check . --config docs/customization/custom-field-example.toml # code-ranker report . --config docs/customization/custom-field-example.toml \ -# --output.scorecard --focus-rule tsr --severity warning --top 1 +# --output.scorecard --focus tsr --severity warning --top 1 # Per-file test-to-source ratio (guarded against divide-by-zero). Every spec field # below is optional, but they drive the UI: `name` is the tooltip title, `short` @@ -72,10 +72,10 @@ stats = { add = ["tsr_big_avg"] } size = { add = ["tsr"] } filter = { add = ["tsr_big"] } -# A project-defined Prompt-Generator preset (a refactoring lens over the custom -# metric). Makes `--focus-rule tsr` / the scorecard rank files by `tsr`. The id is the +# A project-defined Prompt-Generator principle (ranks files by the custom +# metric). Makes `--focus tsr` / the scorecard rank files by `tsr`. The id is the # table key; `label` / `title` default to it. -[presets.TSR] +[principles.TSR] title = "TSR — Trim inline test bulk" sort_metric = "tsr" prompt = """ diff --git a/docs/templates.md b/docs/templates.md index 3b7580b4..7f4f71d6 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -30,7 +30,7 @@ They are converging on **one composition engine** (§4) and one override mechani ### 2.1 Resolution & the `base/` fallback ✅ -A finding links its principle/metric doc via a preset's `doc_url`, resolved in +A finding links its principle/metric doc via a principle's `doc_url`, resolved in [`specs.rs`](../crates/code-ranker-plugins/src/config/specs.rs): ``` @@ -199,7 +199,7 @@ code-ranker report . --prompt HK code-ranker report . --prompt HK --top 5 --focus-path src/engine ``` -- `<ID>` is a preset id (`HK`, `ADP`, `SRP`, …) or a metric key; unknown ids fail +- `<ID>` is a principle id (`HK`, `ADP`, `SRP`, …) or a metric key; unknown ids fail with the known list (same validation as `compose_prompt`). - It runs the normal analysis (the prompt lists the offending modules ranked by the principle's `sort_metric`), composes via the shared engine, and prints. `--top` and @@ -209,7 +209,7 @@ code-ranker report . --prompt HK --top 5 --focus-path src/engine **How it differs from the existing `--output.prompt`**: `--output.prompt` *auto-targets the single worst principle*, requires `--top 1`, and writes a -`…-{preset}.md` file. `--prompt <ID>` is the explicit, name-it-yourself, print-to- +`…-{principle}.md` file. `--prompt <ID>` is the explicit, name-it-yourself, print-to- stdout counterpart — the quick "show me HK" path — and (being a standalone dump) accepts any `--top N` to widen the ranked module list. @@ -242,7 +242,7 @@ See the full flag reference in [code-ranker-cli/CLI.md](code-ranker-cli/CLI.md). The framing prose lives in [`metrics/prompt.md`](../crates/code-ranker-graph/metrics/prompt.md) as Markdown `## <field>` sections (parsed by `prompt_template()` in `builtin.rs`; a project may substitute its own via `prompt_template_from()`), and is carried in the -snapshot as [`PromptTemplate`](../crates/code-ranker-plugin-api/src/preset.rs) so the +snapshot as [`PromptTemplate`](../crates/code-ranker-plugin-api/src/principle.rs) so the CLI and the viewer render identical text from one source. Unlike the principle/metric corpus, `prompt.md` is **internal template prose**: it sits next to `builtin.toml` (not under `languages/`), is not a `<lang>/<ID>` doc, and is not published by @@ -252,7 +252,7 @@ corpus, `prompt.md` is **internal template prose**: it sits next to `builtin.tom |---|---| | `intro` | one-line intent under the title | | `doc_note` | how to read the full principle — points at the offline `code-ranker report --doc <id>` command (`{id}` substituted), not a network URL | -| `task` | the task-protocol bullets (`{id}` → active preset id) | +| `task` | the task-protocol bullets (`{id}` → active principle id) | | `focus` | closing emphasis line | | `cycle_note` | note prepended to a single dependency-cycle's module list | diff --git a/languages/base/AI.md b/languages/base/AI.md new file mode 100644 index 00000000..f50146fc --- /dev/null +++ b/languages/base/AI.md @@ -0,0 +1,43 @@ +# code-ranker — AI agent skill + +**TL;DR**: A short playbook for an AI assistant driving `code-ranker`, plus a +catalog of every principle and metric it checks. Each catalog entry is a +one-paragraph summary; run `code-ranker report --doc <ID>` to print any entry in +full (offline, straight to the terminal). + +## Two commands + +- **`check`** — a gate. Exits non-zero on a violation, writes no files. +- **`report`** — produces artifacts: a JSON snapshot, an HTML viewer, and the + advisory **`scorecard`** (console triage) / **`prompt`** (LLM fix-prompt). Always + exits `0`. + +`[input]` is polymorphic: a directory is analyzed; a `.json` snapshot is read back +with no re-analysis. Keep old `.code-ranker/` snapshots — they are baselines for a +before/after diff (`--baseline <snapshot>`). + +## The two that matter most + +Fix one thing at a time, worst-first. Cycles (**ADP**) are structural — clear them +first; then coupling (**HK**). Focus on one metric or principle with `--focus` and +inspect the worst tier with `--severity warning`. + +- **ADP** — dependency cycles; the module graph should be acyclic. +- **HK** — Henry–Kafura coupling, `HK = sloc × (fan_in × fan_out)²`: a large module + on a busy crossroads of incoming/outgoing dependencies. + +## The fix loop + +```sh +code-ranker check . # the gate verdict +code-ranker report . --output.scorecard --focus cycle --top 1 # focus one metric/principle +code-ranker report . --output.prompt.path=stdout --top 1 # fix-prompt, worst module +``` + +`--focus` takes any catalog id below (a principle like `ADP`, or a metric like +`hk` / `cycle`): focusing on a metric frames the output by that metric; on a +principle, by that design principle. + +## Principles & metrics + +<!-- doc:tldr-index --> diff --git a/languages/base/Cognitive.md b/languages/base/Cognitive.md index 141a6b13..335817de 100644 --- a/languages/base/Cognitive.md +++ b/languages/base/Cognitive.md @@ -68,7 +68,7 @@ applies verbatim here. code-ranker check <path/to/project> --threshold file.cognitive=110 --top 1 # Triage worst-first by cognitive — ranked offenders, no snapshot to parse: -code-ranker report <path/to/project> --output.scorecard --focus-rule cognitive +code-ranker report <path/to/project> --output.scorecard --focus cognitive # After flattening / splitting — confirm it dropped and no new cycle appeared: code-ranker check <path/to/project> --threshold file.cognitive=110 diff --git a/languages/base/Cyclomatic.md b/languages/base/Cyclomatic.md index 3887a6d5..ffc8d57b 100644 --- a/languages/base/Cyclomatic.md +++ b/languages/base/Cyclomatic.md @@ -142,7 +142,7 @@ and move that shared item to a leaf. code-ranker check <path/to/project> --threshold file.cyclomatic=110 --top 1 # Triage worst-first by cyclomatic — ranked offenders, no snapshot to parse: -code-ranker report <path/to/project> --output.scorecard --focus-rule cyclomatic +code-ranker report <path/to/project> --output.scorecard --focus cyclomatic ``` To read how a file's total splits across its functions (which picks the lever), diff --git a/languages/base/Fan-in.md b/languages/base/Fan-in.md index a958f0a1..071ec9b6 100644 --- a/languages/base/Fan-in.md +++ b/languages/base/Fan-in.md @@ -54,8 +54,8 @@ For each high-fan-in module: ## How code-ranker surfaces it -`fan_in` is a first-class node metric, a sort option, and the `FANIN` preset -in the Prompt Generator. The preset ranks modules by fan-in worst-first and +`fan_in` is a first-class node metric, a sort option, and the `FANIN` principle +in the Prompt Generator. The principle ranks modules by fan-in worst-first and pre-selects **incoming** connections, so the prompt shows who depends on each load-bearing module. diff --git a/languages/base/Fan-out.md b/languages/base/Fan-out.md index f6f2953e..3f0e078e 100644 --- a/languages/base/Fan-out.md +++ b/languages/base/Fan-out.md @@ -55,8 +55,8 @@ For each high-fan-out module: ## How code-ranker surfaces it -`fan_out` is a first-class node metric, a sort option, and the `FANOUT` preset -in the Prompt Generator. The preset ranks modules by fan-out worst-first and +`fan_out` is a first-class node metric, a sort option, and the `FANOUT` principle +in the Prompt Generator. The principle ranks modules by fan-out worst-first and pre-selects **outgoing** connections, so the prompt shows exactly what each module pulls in. diff --git a/languages/base/HK.md b/languages/base/HK.md index a7811032..4fd9acb0 100644 --- a/languages/base/HK.md +++ b/languages/base/HK.md @@ -90,8 +90,8 @@ genuine hub you have proven irreducible; for everything else, prefer the split. ## How code-ranker surfaces it -HK is a first-class node metric (`hk`), the default sort, and the `HK` preset -in the Prompt Generator. The preset ranks modules worst-first by HK and +HK is a first-class node metric (`hk`), the default sort, and the `HK` principle +in the Prompt Generator. The principle ranks modules worst-first by HK and pre-selects both incoming and outgoing connections, so the generated prompt shows the full crossroads around each hotspot. @@ -108,7 +108,7 @@ a budget (worst-first; `--top 1` for just the single worst), each finding a self-contained where/issue/why/fix block you can paste into an AI assistant: ```bash -code-ranker report --output.scorecard --focus-rule hk +code-ranker report --output.scorecard --focus hk ``` HK is `sloc × (fan_in × fan_out)²`: the coupling term is squared, so a unit dropped @@ -153,7 +153,7 @@ Pick the canonical technique that matches the seam you found: - **Extract a focused submodule + re-export.** Move the cohesive group into a new module, keep the public path stable with a re-export - (`import module preset; re-export preset.Preset`). Callers don't churn; the + (`import module principle; re-export principle.Principle`). Callers don't churn; the hub's `sloc` drops and the moved item's narrow dependants detach from the hub. - **Move a pure data type (DTO) to its own module.** A plain data structure with no internal dependencies has `fan_out = 0`, so its own HK is `0`. When @@ -205,7 +205,7 @@ code-ranker report \ # Confirm the hotspot's HK actually dropped (and no sibling rose past it) — # re-rank with the scorecard, or let the gate decide (exit 0 = no breach): -code-ranker report --output.scorecard --focus-rule hk +code-ranker report --output.scorecard --focus hk code-ranker check --threshold file.hk=100000 ``` diff --git a/languages/typescript/OCP.md b/languages/typescript/OCP.md index 226782ed..a4a6c949 100644 --- a/languages/typescript/OCP.md +++ b/languages/typescript/OCP.md @@ -191,7 +191,7 @@ for (const mod of plugins) { The host knows only the plugin interface. New plugins ship as separate packages and are discovered via configuration (or via `package.json` `keywords` registries scanned at install time, the -pattern used by ESLint plugins, Babel presets, and the Backstage +pattern used by ESLint plugins, Babel principles, and the Backstage plugin system). The host is closed; the ecosystem is open. ## The Rust tension, in TS terms From bf5fab96236ea54379935dc2e6d11b4c7a09c6fb Mon Sep 17 00:00:00 2001 From: Roman Fedorov <Roman.Fedorov@constructor.tech> Date: Tue, 23 Jun 2026 11:11:07 +0300 Subject: [PATCH 03/40] feat(version): three-version model + required config `version`, bump to 4.0.0-alpha.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent format versions, each its own constant, bumped on its own criterion (documented in docs/versions.md): - **app** — Cargo `[workspace.package] version` → 4.0.0-alpha.1. - **config + CLI** — `code_ranker_graph::version::CONFIG_VERSION` ("4.0"). - **JSON snapshot + viewer** — `…::version::SCHEMA_VERSION` ("4.0", was "3"). config: - `code-ranker.toml` now MUST declare a matching `version`; `config::load` rejects a missing/mismatched value with a directional hint (older → migrate, newer → upgrade) instead of a cryptic `unknown field`. `CONFIG_SCHEMA_VERSION` aliases `CONFIG_VERSION`. Added `version = "4.0"` to the root config + 9 samples. json + viewer: - snapshot `schema_version` synced to `SCHEMA_VERSION`; the renderer injects `window.SCHEMA_VERSION` and the viewer rejects an incompatible swapped-in snapshot (snap-controls). The number lives only at its constant — config/e2e fixtures `format!` it from the constant, never hardcode it. Goldens regenerated (`schema_version`). docs: new docs/versions.md (the 3 surfaces, where each constant lives, when/how to bump, branch discipline); config.md documents the required `version`; PRD/DESIGN/ node_schema/e2e schema_version refs synced to 4.0. The /update-docs checklist gains a format-compatibility/version-bump step (moved early, Step 3). make all + make e2e green. --- Cargo.lock | 10 +- Cargo.toml | 10 +- code-ranker.toml | 1 + crates/code-ranker-cli/Cargo.toml | 3 + crates/code-ranker-cli/src/config/load.rs | 41 +++++++ .../code-ranker-cli/src/config/load_test.rs | 53 ++++++++- crates/code-ranker-cli/src/config/model.rs | 12 ++ crates/code-ranker-cli/src/export.rs | 9 +- crates/code-ranker-cli/src/pipeline_test.rs | 9 +- crates/code-ranker-cli/tests/e2e.rs | 31 +++-- crates/code-ranker-graph/src/lib.rs | 1 + crates/code-ranker-graph/src/snapshot.rs | 8 +- crates/code-ranker-graph/src/version.rs | 32 ++++++ .../c/tests/sample/code-ranker-report.json | 2 +- .../languages/c/tests/sample/code-ranker.toml | 1 + .../cpp/tests/sample/code-ranker-report.json | 2 +- .../cpp/tests/sample/code-ranker.toml | 1 + .../tests/sample/code-ranker-report.json | 2 +- .../csharp/tests/sample/code-ranker.toml | 1 + .../go/tests/sample/code-ranker-report.json | 2 +- .../go/tests/sample/code-ranker.toml | 1 + .../tests/sample/code-ranker-report.json | 2 +- .../javascript/tests/sample/code-ranker.toml | 1 + .../tests/sample/code-ranker-report.json | 2 +- .../markdown/tests/sample/code-ranker.toml | 1 + .../tests/sample/code-ranker-report.json | 2 +- .../python/tests/sample/code-ranker.toml | 1 + .../rust/tests/sample/code-ranker-report.json | 2 +- .../rust/tests/sample/code-ranker.toml | 1 + .../tests/sample/code-ranker-report.json | 2 +- .../typescript/tests/sample/code-ranker.toml | 1 + .../src/assets/snap-controls.js | 31 +++-- crates/code-ranker-viewer/src/lib.rs | 6 +- docs/DESIGN.md | 8 +- docs/PRD.md | 16 +-- docs/code-ranker-cli/config.md | 14 +++ docs/e2e.md | 2 +- docs/node_schema.md | 2 +- docs/versions.md | 107 ++++++++++++++++++ 39 files changed, 368 insertions(+), 65 deletions(-) create mode 100644 crates/code-ranker-graph/src/version.rs create mode 100644 docs/versions.md diff --git a/Cargo.lock b/Cargo.lock index 3ee9db7e..ccadcc00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -268,7 +268,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "code-ranker" -version = "3.0.2" +version = "4.0.0-alpha.1" dependencies = [ "anyhow", "chrono", @@ -286,7 +286,7 @@ dependencies = [ [[package]] name = "code-ranker-graph" -version = "3.0.2" +version = "4.0.0-alpha.1" dependencies = [ "cel", "chrono", @@ -298,7 +298,7 @@ dependencies = [ [[package]] name = "code-ranker-plugin-api" -version = "3.0.2" +version = "4.0.0-alpha.1" dependencies = [ "anyhow", "chrono", @@ -309,7 +309,7 @@ dependencies = [ [[package]] name = "code-ranker-plugins" -version = "3.0.2" +version = "4.0.0-alpha.1" dependencies = [ "anyhow", "cargo_metadata", @@ -336,7 +336,7 @@ dependencies = [ [[package]] name = "code-ranker-viewer" -version = "3.0.2" +version = "4.0.0-alpha.1" dependencies = [ "anyhow", "code-ranker-graph", diff --git a/Cargo.toml b/Cargo.toml index de518e8c..c7fba08c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "3" [workspace.package] -version = "3.0.2" +version = "4.0.0-alpha.1" edition = "2024" rust-version = "1.88" license = "Apache-2.0" @@ -12,10 +12,10 @@ keywords = ["dependency-graph", "coupling", "refactoring", "code-quality", "stat categories = ["development-tools", "command-line-utilities"] [workspace.dependencies] -code-ranker-graph = { path = "crates/code-ranker-graph", version = "3.0.2" } -code-ranker-plugin-api = { path = "crates/code-ranker-plugin-api", version = "3.0.2" } -code-ranker-plugins = { path = "crates/code-ranker-plugins", version = "3.0.2" } -code-ranker-viewer = { path = "crates/code-ranker-viewer", version = "3.0.2" } +code-ranker-graph = { path = "crates/code-ranker-graph", version = "4.0.0-alpha.1" } +code-ranker-plugin-api = { path = "crates/code-ranker-plugin-api", version = "4.0.0-alpha.1" } +code-ranker-plugins = { path = "crates/code-ranker-plugins", version = "4.0.0-alpha.1" } +code-ranker-viewer = { path = "crates/code-ranker-viewer", version = "4.0.0-alpha.1" } anyhow = "1.0" cel = "0.13" diff --git a/code-ranker.toml b/code-ranker.toml index 9eb0f1a9..ce7a3023 100644 --- a/code-ranker.toml +++ b/code-ranker.toml @@ -1,3 +1,4 @@ +version = "4.0" # code-ranker.toml — project-level configuration for code-ranker. # Discovery order: --config PATH > ./code-ranker.toml > <target>/code-ranker.toml > # Cargo.toml [workspace.metadata.code-ranker]. CLI flags always win over the file. diff --git a/crates/code-ranker-cli/Cargo.toml b/crates/code-ranker-cli/Cargo.toml index 341c27d6..c250d54e 100644 --- a/crates/code-ranker-cli/Cargo.toml +++ b/crates/code-ranker-cli/Cargo.toml @@ -28,3 +28,6 @@ serde_json = { workspace = true } [dev-dependencies] tempfile = { workspace = true } +# For e2e fixtures to reference the single format-version constant (SCHEMA_VERSION) +# instead of hardcoding it. +code-ranker-graph = { workspace = true } diff --git a/crates/code-ranker-cli/src/config/load.rs b/crates/code-ranker-cli/src/config/load.rs index 57e1ef10..1b4327f7 100644 --- a/crates/code-ranker-cli/src/config/load.rs +++ b/crates/code-ranker-cli/src/config/load.rs @@ -74,6 +74,7 @@ pub fn load( apply_inline_overrides(&mut config, &inline)?; apply_cli_overrides(&mut config, ignore_paths, cycle_rules, thresholds)?; validate_thresholds(&config)?; + validate_schema_version(&config, &source_file)?; Ok(LoadedConfig { config, source_file, @@ -108,6 +109,46 @@ fn validate_thresholds(cfg: &Config) -> Result<()> { Ok(()) } +/// Require a discovered config file to declare a compatible `version`. Pure +/// built-in defaults (no file found) have nothing to version, so the check is +/// skipped there. An exact `major.minor` mismatch fails with a directional hint +/// (migrate the config vs upgrade the tool) — far clearer than the `unknown field` +/// error a stale schema would otherwise raise under `deny_unknown_fields`. +fn validate_schema_version(cfg: &Config, source_file: &Option<String>) -> Result<()> { + let Some(src) = source_file else { + return Ok(()); + }; + let want = super::model::CONFIG_SCHEMA_VERSION; + match cfg.version.as_deref() { + Some(v) if v == want => Ok(()), + None => anyhow::bail!( + "config {src} is missing the required `version`. Add `version = \"{want}\"` \ + (the config-schema version this code-ranker supports)." + ), + Some(v) => { + let hint = match (parse_major_minor(v), parse_major_minor(want)) { + (Some(got), Some(exp)) if got < exp => "migrate the config to the new schema", + (Some(got), Some(exp)) if got > exp => { + "upgrade code-ranker to a version that supports it" + } + _ => "migrate the config, or upgrade code-ranker", + }; + anyhow::bail!( + "config {src} declares schema `version = {v:?}`, but this code-ranker expects \ + {want:?} — {hint}." + ) + } + } +} + +/// Parse a `major.minor` (ignoring any patch/pre-release tail) for ordering. +fn parse_major_minor(s: &str) -> Option<(u32, u32)> { + let mut it = s.split('.'); + let major = it.next()?.parse().ok()?; + let minor = it.next().unwrap_or("0").parse().ok()?; + Some((major, minor)) +} + /// Discover the user's config as a raw [`Table`] (NOT yet deserialized into /// [`Config`]) so the caller can deep-merge it over the built-in defaults. /// The config layers to merge over the built-in defaults, in apply order (later diff --git a/crates/code-ranker-cli/src/config/load_test.rs b/crates/code-ranker-cli/src/config/load_test.rs index 8844bea0..8220d21c 100644 --- a/crates/code-ranker-cli/src/config/load_test.rs +++ b/crates/code-ranker-cli/src/config/load_test.rs @@ -1,5 +1,14 @@ use super::*; +/// A config-file body prefixed with the required `version` line. Fixtures must not +/// hardcode the number — it comes from the single `CONFIG_VERSION` constant. +fn v(body: &str) -> String { + format!( + "version = \"{}\"\n{body}", + code_ranker_graph::version::CONFIG_VERSION + ) +} + #[test] fn load_merges_explicit_config_over_builtin_defaults() { // A partial `--config` file: it overrides one key and a threshold; every @@ -8,7 +17,7 @@ fn load_merges_explicit_config_over_builtin_defaults() { let cfg = dir.path().join("ci.toml"); std::fs::write( &cfg, - "[ignore]\ntests = false\n[rules.thresholds.file]\nhk = \"1M\"\n", + v("[ignore]\ntests = false\n[rules.thresholds.file]\nhk = \"1M\"\n"), ) .unwrap(); @@ -30,13 +39,46 @@ fn load_merges_explicit_config_over_builtin_defaults() { ); } +#[test] +fn load_requires_matching_schema_version() { + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("code-ranker.toml"); + let run = || load(dir.path(), &[cfg.display().to_string()], &[], &[], &[]); + + let err = || format!("{:#}", run().err().unwrap()); + + // Missing `version` → error naming the required value. + std::fs::write(&cfg, "[ignore]\ntests = false\n").unwrap(); + assert!(err().contains("missing the required `version`")); + + // Older schema → migrate hint. + std::fs::write(&cfg, "version = \"1.0\"\n").unwrap(); + let m = err(); + assert!( + m.contains("expects") && m.contains("migrate the config"), + "{m}" + ); + + // Newer schema (config from a future build) → upgrade hint. + std::fs::write(&cfg, "version = \"99.0\"\n").unwrap(); + assert!(err().contains("upgrade code-ranker")); + + // Unparseable version → generic both-ways hint (neither side orders). + std::fs::write(&cfg, "version = \"abc\"\n").unwrap(); + assert!(err().contains("migrate the config, or upgrade")); + + // Matching schema → ok. + std::fs::write(&cfg, v("[ignore]\ntests = false\n")).unwrap(); + assert!(run().is_ok()); +} + #[test] fn load_layers_multiple_config_files_in_order_last_wins() { // Two `--config FILE` layers + an inline override; later wins at each step. let dir = tempfile::tempdir().unwrap(); let base = dir.path().join("base.toml"); let over = dir.path().join("over.toml"); - std::fs::write(&base, "[rules.thresholds.file]\nhk = 100\nsloc = 800\n").unwrap(); + std::fs::write(&base, v("[rules.thresholds.file]\nhk = 100\nsloc = 800\n")).unwrap(); std::fs::write(&over, "[rules.thresholds.file]\nhk = 5\n").unwrap(); let loaded = load( @@ -69,7 +111,7 @@ fn load_auto_discovers_code_ranker_toml_in_workspace() { let dir = tempfile::tempdir().unwrap(); std::fs::write( dir.path().join("code-ranker.toml"), - "[rules.thresholds.file]\nhk = 42\n", + v("[rules.thresholds.file]\nhk = 42\n"), ) .unwrap(); @@ -86,7 +128,10 @@ fn load_auto_discovers_cargo_workspace_metadata() { let dir = tempfile::tempdir().unwrap(); std::fs::write( dir.path().join("Cargo.toml"), - "[workspace]\nmembers = []\n[workspace.metadata.code-ranker.rules.thresholds.file]\nhk = 7\n", + format!( + "[workspace]\nmembers = []\n[workspace.metadata.code-ranker]\nversion = \"{}\"\n[workspace.metadata.code-ranker.rules.thresholds.file]\nhk = 7\n", + code_ranker_graph::version::CONFIG_VERSION + ), ) .unwrap(); diff --git a/crates/code-ranker-cli/src/config/model.rs b/crates/code-ranker-cli/src/config/model.rs index 5f597f4a..1dc7bc9d 100644 --- a/crates/code-ranker-cli/src/config/model.rs +++ b/crates/code-ranker-cli/src/config/model.rs @@ -29,9 +29,21 @@ static BUILTIN: LazyLock<Config> = // deadlocking. Per-field defaults are lazy and use each field's own `Default` // (`None`/empty for the scalar/map fields; the section structs' defaults are never // invoked because `defaults.toml` always carries those sections). +/// The version a `code-ranker.toml` must declare in `version` — the **config + CLI** +/// format version [`code_ranker_graph::version::CONFIG_VERSION`] (separate from the +/// JSON-snapshot `SCHEMA_VERSION`; see `docs/versions.md`). The loader requires an +/// exact match, failing with a migrate / upgrade hint instead of a cryptic +/// `unknown field` error. +pub const CONFIG_SCHEMA_VERSION: &str = code_ranker_graph::version::CONFIG_VERSION; + #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct Config { + /// Config-schema version (`major.minor`, e.g. `"4.0"`) — **required** in a + /// `code-ranker.toml`. Validated against [`CONFIG_SCHEMA_VERSION`] at load. + /// `Option` so a missing value yields our migrate-hint error, not serde's. + #[serde(default)] + pub version: Option<String>, /// Default plugin name (e.g. "rust", "python"). Overridden by --plugin. #[serde(default)] pub plugin: Option<String>, diff --git a/crates/code-ranker-cli/src/export.rs b/crates/code-ranker-cli/src/export.rs index 192a7a17..472a2a6d 100644 --- a/crates/code-ranker-cli/src/export.rs +++ b/crates/code-ranker-cli/src/export.rs @@ -100,7 +100,14 @@ mod tests { let dir = tempfile::tempdir().unwrap(); // A partial project config: one override; everything else inherits defaults. let cfg = dir.path().join("code-ranker.toml"); - std::fs::write(&cfg, "[ignore]\ntests = false\n").unwrap(); + std::fs::write( + &cfg, + format!( + "version = \"{}\"\n[ignore]\ntests = false\n", + code_ranker_graph::version::CONFIG_VERSION + ), + ) + .unwrap(); let out = dir.path().join("full.toml"); export_full_config( diff --git a/crates/code-ranker-cli/src/pipeline_test.rs b/crates/code-ranker-cli/src/pipeline_test.rs index 21f769e5..76db6e24 100644 --- a/crates/code-ranker-cli/src/pipeline_test.rs +++ b/crates/code-ranker-cli/src/pipeline_test.rs @@ -47,8 +47,10 @@ fn gate_thresholds_uses_gate_as_warning_and_reconciles_info() { let cfg_path = dir.path().join("code-ranker.toml"); fs::write( &cfg_path, - r#" -[metrics.below] + format!( + "version = \"{}\"\n{}", + code_ranker_graph::version::CONFIG_VERSION, + r#"[metrics.below] formula_cel = "sloc" info = 50 @@ -60,7 +62,8 @@ info = 200 hk = 500000 below = 100 above = 100 -"#, +"# + ), ) .unwrap(); let loaded = diff --git a/crates/code-ranker-cli/tests/e2e.rs b/crates/code-ranker-cli/tests/e2e.rs index 419c1939..895bbceb 100644 --- a/crates/code-ranker-cli/tests/e2e.rs +++ b/crates/code-ranker-cli/tests/e2e.rs @@ -31,8 +31,15 @@ use std::path::{Path, PathBuf}; use std::process::Command; +use code_ranker_graph::version::CONFIG_VERSION; use serde_json::Value; +/// A `code-ranker.toml` body prefixed with the required `version` line. The number +/// is the single `CONFIG_VERSION` constant, never hardcoded in a fixture. +fn vcfg(body: &str) -> String { + format!("version = \"{CONFIG_VERSION}\"\n{body}") +} + /// Fields that MUST differ between the golden (captured earlier) and a fresh /// run — otherwise we are not actually exercising the binary. const MUST_CHANGE: &[&str] = &["generated_at"]; @@ -1163,11 +1170,13 @@ fn user_defined_metric_is_computed_and_emitted() { .unwrap(); std::fs::write( p.join("code-ranker.toml"), - "[metrics.comment_ratio]\n\ - formula_cel = \"sloc > 0.0 ? cloc / sloc * 100.0 : 0.0\"\n\ - label = \"Comments %\"\n\ - direction = \"higher_better\"\n\ - group = \"loc\"\n", + vcfg( + "[metrics.comment_ratio]\n\ + formula_cel = \"sloc > 0.0 ? cloc / sloc * 100.0 : 0.0\"\n\ + label = \"Comments %\"\n\ + direction = \"higher_better\"\n\ + group = \"loc\"\n", + ), ) .unwrap(); let out = p.join("out.json"); @@ -1220,9 +1229,11 @@ fn user_defined_aggregate_lands_in_stats() { std::fs::write(p.join("b.py"), "def g(y):\n return y\n").unwrap(); std::fs::write( p.join("code-ranker.toml"), - "[metrics.cyc_mean]\n\ - scope = \"graph\"\n\ - formula_cel = \"agg('cyclomatic', 'avg', 'not_empty')\"\n", + vcfg( + "[metrics.cyc_mean]\n\ + scope = \"graph\"\n\ + formula_cel = \"agg('cyclomatic', 'avg', 'not_empty')\"\n", + ), ) .unwrap(); let out = p.join("out.json"); @@ -1262,7 +1273,7 @@ fn functions_level_is_opt_in() { .unwrap(); let run = |cfg: &str| -> Value { - std::fs::write(p.join("code-ranker.toml"), cfg).unwrap(); + std::fs::write(p.join("code-ranker.toml"), vcfg(cfg)).unwrap(); let out = p.join("out.json"); let status = Command::new(env!("CARGO_BIN_EXE_code-ranker")) .current_dir(p) @@ -1310,7 +1321,7 @@ fn empty_metric_warns_on_stderr() { std::fs::write(p.join("m.py"), "def f(x):\n return x\n").unwrap(); std::fs::write( p.join("code-ranker.toml"), - "[metrics.bad]\nformula_cel = \"slocc / 100.0\"\n", // `slocc` is a typo for `sloc` + vcfg("[metrics.bad]\nformula_cel = \"slocc / 100.0\"\n"), // `slocc` is a typo for `sloc` ) .unwrap(); let out = Command::new(env!("CARGO_BIN_EXE_code-ranker")) diff --git a/crates/code-ranker-graph/src/lib.rs b/crates/code-ranker-graph/src/lib.rs index 9aa7de50..416d1af5 100644 --- a/crates/code-ranker-graph/src/lib.rs +++ b/crates/code-ranker-graph/src/lib.rs @@ -22,6 +22,7 @@ pub mod relativize; pub mod serialize; pub mod snapshot; pub mod stats; +pub mod version; pub use attrs::{num_attr, round_sig3}; pub use cycles::annotate_cycles; diff --git a/crates/code-ranker-graph/src/snapshot.rs b/crates/code-ranker-graph/src/snapshot.rs index 656e273e..0a1c11c3 100644 --- a/crates/code-ranker-graph/src/snapshot.rs +++ b/crates/code-ranker-graph/src/snapshot.rs @@ -13,10 +13,10 @@ use code_ranker_plugin_api::Principle; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; -/// The snapshot schema version this build produces and can read back. A -/// `--baseline` (or snapshot input) with a different version is rejected with a -/// structured error rather than silently mis-parsed. -pub const SCHEMA_VERSION: &str = "3"; +/// The JSON-snapshot + viewer format version (re-exported from [`crate::version`]). +/// Written as `schema_version`, rejected on mismatch in `analyze.rs`, and checked +/// in the viewer. See `docs/versions.md`. +pub use crate::version::SCHEMA_VERSION; /// Per-stage timing in milliseconds, in execution order. #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/code-ranker-graph/src/version.rs b/crates/code-ranker-graph/src/version.rs new file mode 100644 index 00000000..0563ccc5 --- /dev/null +++ b/crates/code-ranker-graph/src/version.rs @@ -0,0 +1,32 @@ +//! Project format-version constants — the single home for the versions every +//! compatibility check shares. There are **three** independent versions, each +//! guarding one surface and bumped on its own criterion (see `docs/versions.md`): +//! +//! 1. **app** — the release version, Cargo's `[workspace.package] version` +//! (`env!("CARGO_PKG_VERSION")`). Not defined here. +//! 2. **config + CLI** — [`CONFIG_VERSION`]. +//! 3. **JSON snapshot + viewer** — [`SCHEMA_VERSION`]. +//! +//! (2) and (3) are `major.minor` of the app release that last changed that surface. +//! They may share a value (both `"4.0"` today) but move independently. The number +//! lives ONLY here — every consumer imports it, never hardcodes it. + +/// The **config + CLI** format version. A `code-ranker.toml` must declare a +/// matching `version` (checked in `config::load`); the CLI surface (flags / +/// subcommands / output) is documented against the same number. +/// +/// **Bump when** the TOML config schema or the CLI surface changes — a **minor** +/// for an additive/back-compatible change (new optional key or flag), a **major** +/// for a breaking one (renamed/removed key, flag or section). Set it to the app +/// `major.minor` of the release that ships the change. +pub const CONFIG_VERSION: &str = "4.0"; + +/// The **JSON snapshot + viewer** format version. Written as the snapshot's +/// `schema_version`, rejected on mismatch when a snapshot is read back +/// (`analyze.rs`), and checked in the browser on a snapshot swap (injected as +/// `window.SCHEMA_VERSION`). +/// +/// **Bump when** the snapshot JSON shape changes (a field added/renamed/removed, +/// or the viewer's read contract changes) — same minor/major rule as +/// [`CONFIG_VERSION`], set to the app `major.minor` of the shipping release. +pub const SCHEMA_VERSION: &str = "4.0"; 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 0b4849dc..237c7917 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 @@ -749,7 +749,7 @@ "roots": { "target": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/c/tests/sample" }, - "schema_version": "3", + "schema_version": "4.0", "target": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/c/tests/sample", "timings": [ { diff --git a/crates/code-ranker-plugins/src/languages/c/tests/sample/code-ranker.toml b/crates/code-ranker-plugins/src/languages/c/tests/sample/code-ranker.toml index 97528b07..f61f5861 100644 --- a/crates/code-ranker-plugins/src/languages/c/tests/sample/code-ranker.toml +++ b/crates/code-ranker-plugins/src/languages/c/tests/sample/code-ranker.toml @@ -1,3 +1,4 @@ +version = "4.0" # Self-contained config for the code-ranker "c" sample fixture. plugin = "c" 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 50ea355e..8227536a 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 @@ -759,7 +759,7 @@ "roots": { "target": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/cpp/tests/sample" }, - "schema_version": "3", + "schema_version": "4.0", "target": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/cpp/tests/sample", "timings": [ { diff --git a/crates/code-ranker-plugins/src/languages/cpp/tests/sample/code-ranker.toml b/crates/code-ranker-plugins/src/languages/cpp/tests/sample/code-ranker.toml index d104406e..cb76a532 100644 --- a/crates/code-ranker-plugins/src/languages/cpp/tests/sample/code-ranker.toml +++ b/crates/code-ranker-plugins/src/languages/cpp/tests/sample/code-ranker.toml @@ -1,3 +1,4 @@ +version = "4.0" # Self-contained config for the code-ranker "cpp" sample fixture. plugin = "cpp" 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 33ca4e82..cda49856 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 @@ -695,7 +695,7 @@ "roots": { "target": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/csharp/tests/sample" }, - "schema_version": "3", + "schema_version": "4.0", "target": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/csharp/tests/sample", "timings": [ { diff --git a/crates/code-ranker-plugins/src/languages/csharp/tests/sample/code-ranker.toml b/crates/code-ranker-plugins/src/languages/csharp/tests/sample/code-ranker.toml index f1c8320f..b9216f41 100644 --- a/crates/code-ranker-plugins/src/languages/csharp/tests/sample/code-ranker.toml +++ b/crates/code-ranker-plugins/src/languages/csharp/tests/sample/code-ranker.toml @@ -1,3 +1,4 @@ +version = "4.0" # Self-contained config for the code-ranker "csharp" sample fixture. plugin = "csharp" 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 a5c2b932..52967dd3 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 @@ -697,7 +697,7 @@ "roots": { "target": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/go/tests/sample" }, - "schema_version": "3", + "schema_version": "4.0", "target": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/go/tests/sample", "timings": [ { diff --git a/crates/code-ranker-plugins/src/languages/go/tests/sample/code-ranker.toml b/crates/code-ranker-plugins/src/languages/go/tests/sample/code-ranker.toml index df39c700..e5276617 100644 --- a/crates/code-ranker-plugins/src/languages/go/tests/sample/code-ranker.toml +++ b/crates/code-ranker-plugins/src/languages/go/tests/sample/code-ranker.toml @@ -1,3 +1,4 @@ +version = "4.0" # Self-contained config for the code-ranker "go" sample fixture. # Pin the plugin and keep test files in the graph (ignore.tests = false) so the # fixture is reproducible regardless of any repo-level config. diff --git a/crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker-report.json b/crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker-report.json index f66cda60..6ef567e8 100644 --- a/crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker-report.json +++ b/crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker-report.json @@ -938,7 +938,7 @@ "roots": { "target": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/javascript/tests/sample" }, - "schema_version": "3", + "schema_version": "4.0", "target": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/javascript/tests/sample", "timings": [ { diff --git a/crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker.toml b/crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker.toml index b219ef53..02be682d 100644 --- a/crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker.toml +++ b/crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker.toml @@ -1,3 +1,4 @@ +version = "4.0" # Self-contained config for the code-ranker "javascript" sample fixture. # Pin the plugin and KEEP test files in the graph (ignore.tests = false) so the # fixture is reproducible regardless of any repo-level config and so that test diff --git a/crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker-report.json b/crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker-report.json index 3d18d8e8..1468dbd4 100644 --- a/crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker-report.json +++ b/crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker-report.json @@ -244,7 +244,7 @@ "roots": { "target": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/markdown/tests/sample" }, - "schema_version": "3", + "schema_version": "4.0", "target": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/markdown/tests/sample", "timings": [ { diff --git a/crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker.toml b/crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker.toml index 2fc17758..6ba24753 100644 --- a/crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker.toml +++ b/crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker.toml @@ -1,3 +1,4 @@ +version = "4.0" # Self-contained config for the code-ranker "markdown" sample fixture. plugin = "markdown" 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 dcfab8a5..d5978e6f 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 @@ -1047,7 +1047,7 @@ "roots": { "target": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/python/tests/sample" }, - "schema_version": "3", + "schema_version": "4.0", "target": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/python/tests/sample", "timings": [ { diff --git a/crates/code-ranker-plugins/src/languages/python/tests/sample/code-ranker.toml b/crates/code-ranker-plugins/src/languages/python/tests/sample/code-ranker.toml index 68728091..f5161853 100644 --- a/crates/code-ranker-plugins/src/languages/python/tests/sample/code-ranker.toml +++ b/crates/code-ranker-plugins/src/languages/python/tests/sample/code-ranker.toml @@ -1,3 +1,4 @@ +version = "4.0" # Self-contained config for the code-ranker "python" sample fixture. # Pin the plugin and KEEP test files in the graph (ignore.tests = false) so the # fixture is reproducible regardless of any repo-level config and so that test 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 0b0d285e..463b74a7 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 @@ -1736,7 +1736,7 @@ "registry": "/Users/roman/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f", "target": "/Users/roman/work/code-ranker/crates/code-ranker-plugins/src/languages/rust/tests/sample" }, - "schema_version": "3", + "schema_version": "4.0", "target": "/Users/roman/work/code-ranker/crates/code-ranker-plugins/src/languages/rust/tests/sample", "timings": [ { diff --git a/crates/code-ranker-plugins/src/languages/rust/tests/sample/code-ranker.toml b/crates/code-ranker-plugins/src/languages/rust/tests/sample/code-ranker.toml index af68e787..c664bdf2 100644 --- a/crates/code-ranker-plugins/src/languages/rust/tests/sample/code-ranker.toml +++ b/crates/code-ranker-plugins/src/languages/rust/tests/sample/code-ranker.toml @@ -1,3 +1,4 @@ +version = "4.0" # Self-contained config for the code-ranker "rust" sample fixture. # Pin the plugin and KEEP test files in the graph (ignore.tests = false) so the # fixture is reproducible regardless of any repo-level config and so that test diff --git a/crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker-report.json b/crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker-report.json index 6589a095..e56dccb6 100644 --- a/crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker-report.json +++ b/crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker-report.json @@ -1005,7 +1005,7 @@ "roots": { "target": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/typescript/tests/sample" }, - "schema_version": "3", + "schema_version": "4.0", "target": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/typescript/tests/sample", "timings": [ { diff --git a/crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker.toml b/crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker.toml index 503b7a21..c7bebd14 100644 --- a/crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker.toml +++ b/crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker.toml @@ -1,3 +1,4 @@ +version = "4.0" # Self-contained config for the code-ranker "typescript" sample fixture. # Pin the plugin and KEEP test files in the graph (ignore.tests = false) so the # fixture is reproducible regardless of any repo-level config and so that test diff --git a/crates/code-ranker-viewer/src/assets/snap-controls.js b/crates/code-ranker-viewer/src/assets/snap-controls.js index 1d5ae373..bb7c55a9 100644 --- a/crates/code-ranker-viewer/src/assets/snap-controls.js +++ b/crates/code-ranker-viewer/src/assets/snap-controls.js @@ -320,15 +320,28 @@ function readEmbeddedSnapshot(id) { // Parse a snapshot from uploaded file text — either a raw JSON snapshot, or a code-ranker // HTML report with the snapshot embedded (prefer `cs-current`, else `cs-baseline`). +// Rejects a snapshot whose format version this viewer can't read (single +// `window.SCHEMA_VERSION`, injected by the renderer from the build's SCHEMA_VERSION). function extractSnapshotFromText(text) { const s = text.trim(); - if (s.startsWith('{')) return JSON.parse(s); - const doc = new DOMParser().parseFromString(text, 'text/html'); - const read = id => { - const t = doc.getElementById(id)?.textContent?.trim(); - return t && t !== 'null' ? JSON.parse(t) : null; - }; - return read('cs-current') || read('cs-baseline'); + let snap; + if (s.startsWith('{')) { + snap = JSON.parse(s); + } else { + const doc = new DOMParser().parseFromString(text, 'text/html'); + const read = id => { + const t = doc.getElementById(id)?.textContent?.trim(); + return t && t !== 'null' ? JSON.parse(t) : null; + }; + snap = read('cs-current') || read('cs-baseline'); + } + const want = window.SCHEMA_VERSION; + if (snap && want && snap.schema_version !== want) { + throw new Error( + `snapshot schema_version "${snap.schema_version}" ≠ this viewer's "${want}" — ` + + `regenerate the report with a matching code-ranker version`); + } + return snap; } // Manual snapshot swap: load a different baseline/current snapshot (.json or .html) into the viewer. @@ -344,7 +357,7 @@ function setupFileControls() { if (!file) return; const reader = new FileReader(); reader.onload = e => { - try { window.BASELINE = extractSnapshotFromText(e.target.result); } catch { alert('Invalid snapshot file'); return; } + try { window.BASELINE = extractSnapshotFromText(e.target.result); } catch (err) { alert(err.message || 'Invalid snapshot file'); return; } recomputeAll(); }; reader.readAsText(file); @@ -356,7 +369,7 @@ function setupFileControls() { if (!file) return; const reader = new FileReader(); reader.onload = e => { - try { window.CURRENT = extractSnapshotFromText(e.target.result); } catch { alert('Invalid snapshot file'); return; } + try { window.CURRENT = extractSnapshotFromText(e.target.result); } catch (err) { alert(err.message || 'Invalid snapshot file'); return; } recomputeAll(); }; reader.readAsText(file); diff --git a/crates/code-ranker-viewer/src/lib.rs b/crates/code-ranker-viewer/src/lib.rs index 16b658fd..084c5e93 100644 --- a/crates/code-ranker-viewer/src/lib.rs +++ b/crates/code-ranker-viewer/src/lib.rs @@ -77,8 +77,12 @@ pub fn render_html_viewer(baseline: Option<&Snapshot>, current: Option<&Snapshot json.replace("</", "<\\/") ) }; + // Expose the build's format version so the JS can reject a swapped-in snapshot + // (snap-controls upload) whose `schema_version` this viewer can't read. The + // embedded snapshots always match — this guards foreign uploads. let data_script = format!( - "{}\n{}", + "<script>window.SCHEMA_VERSION = \"{}\";</script>\n{}\n{}", + code_ranker_graph::snapshot::SCHEMA_VERSION, embed("cs-baseline", baseline), embed("cs-current", current), ); diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 4e107949..c6d55e9d 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -287,7 +287,7 @@ keys it understands, described per level by the semantics dictionaries. | CycleGroup | SCC with ≥ 2 nodes: `kind: String` (`"mutual"` for a 2-node SCC, `"chain"` for 3+), `nodes: Vec<NodeId>`. Each member node also carries a `cycle` attribute. | `crates/code-ranker-graph/src/level_graph.rs` | | LevelUi | Computed UI hints: `default_sort`, `sort`, `size`, `card`, `columns`, `summary` — each a curated metric order filtered to the attributes present on internal nodes, so the viewer renders them verbatim and hardcodes none of it — plus an optional `grouping` (carried through from the level spec, pruned to a usable attribute) telling the viewer how to cluster diagram nodes. | `crates/code-ranker-graph/src/level_graph.rs` | | LevelGraph | One analysis level in the snapshot: the semantics dictionaries (`edge_kinds`/`node_attributes`/`edge_attributes`/`attribute_groups`/`node_kinds`/`cycle_kinds`) + `nodes` + `edges` + `cycles: Vec<CycleGroup>` + `stats: BTreeMap<String, AttrValue>` (flat averages) + `ui: LevelUi`. | `crates/code-ranker-graph/src/level_graph.rs` | -| Snapshot | The `.json` artifact: `schema_version: "3"`, `generated_at`, `command`, `workspace`, `target`, `plugin`, `config_file?`, `versions`, `roots`, `git?`, `timings`, `graphs: BTreeMap<String, LevelGraph>`, top-level `principles: Vec<Principle>`, and `prompt: PromptTemplate` (the Prompt-Generator scaffolding prose, read by both the CLI and the viewer). Serialized via `to_canonical_string_pretty` — **canonical JSON** (alphabetical keys; `nodes`/`edges` sorted). | `crates/code-ranker-graph/src/snapshot.rs` | +| Snapshot | The `.json` artifact: `schema_version: "4.0"`, `generated_at`, `command`, `workspace`, `target`, `plugin`, `config_file?`, `versions`, `roots`, `git?`, `timings`, `graphs: BTreeMap<String, LevelGraph>`, top-level `principles: Vec<Principle>`, and `prompt: PromptTemplate` (the Prompt-Generator scaffolding prose, read by both the CLI and the viewer). Serialized via `to_canonical_string_pretty` — **canonical JSON** (alphabetical keys; `nodes`/`edges` sorted). | `crates/code-ranker-graph/src/snapshot.rs` | | StageTime | Per-stage timing entry: `stage`, `ms`, `detail`. Stored in `Snapshot.timings` in execution order. | `crates/code-ranker-graph/src/snapshot.rs` | **Relationships**: @@ -753,7 +753,7 @@ See [§3.7 Plugin System](#37-plugin-system). - **Location**: defined by `Snapshot`, `Node`, `Edge` structs in `crates/code-ranker-graph/src/` -- **Versioning**: `schema_version: "3"`; additive fields are minor; +- **Versioning**: `schema_version: "4.0"`; additive fields are minor; breaking changes require a major-version bump ### 3.4 Internal Dependencies @@ -980,13 +980,13 @@ dictionaries with the structural graph and the computed cycles/stats: ```json { - "schema_version": "3", + "schema_version": "4.0", "generated_at": "2026-05-22T11:22:33Z", "command": "code-ranker report /path/to/axum-api --plugin rust", "workspace": "/Users/alice/projects/code-ranker", "target": "/Users/alice/projects/axum-api", "plugin": "rust", - "versions": { "code-ranker": "3.0.2", "rustc": "1.78.0" }, + "versions": { "code-ranker": "4.0.0-alpha.1", "rustc": "1.78.0" }, "roots": { "registry": "/Users/alice/.cargo/registry/src/index.crates.io-abc123", "target": "/Users/alice/projects/axum-api" diff --git a/docs/PRD.md b/docs/PRD.md index 31df1918..43c5a735 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -255,21 +255,21 @@ required. - [x] `p1` - **ID**: `cpt-code-ranker-fr-snapshot-meta` Each `code-ranker report` run produces a single `.json` file -(`schema_version: "3"`). The file combines metadata and the `graphs` map (one +(`schema_version: "4.0"`). The file combines metadata and the `graphs` map (one entry per analysis level — today only `files`) in one document. Each level bundles its semantics dictionaries with the structural graph and computed data (see §7.3 for the full shape): ```json { - "schema_version": "3", + "schema_version": "4.0", "generated_at": "2026-05-22T11:22:33Z", "command": "code-ranker report /path/to/axum-api --plugin rust", "workspace": "/Users/alice/projects/code-ranker", "target": "/Users/alice/projects/axum-api", "plugin": "rust", "config_file": "/Users/alice/projects/axum-api/code-ranker.toml", - "versions": { "code-ranker": "3.0.2", "rustc": "1.78.0" }, + "versions": { "code-ranker": "4.0.0-alpha.1", "rustc": "1.78.0" }, "roots": { "registry": "/Users/alice/.cargo/registry/src/index.crates.io-abc123", "target": "/Users/alice/projects/axum-api" @@ -297,7 +297,7 @@ bundles its semantics dictionaries with the structural graph and computed data Top-level fields: -- `schema_version` — `"3"` (the generic property-graph format) +- `schema_version` — `"4.0"` (the generic property-graph format) - `generated_at` — ISO-8601 timestamp - `command` — full command line as typed - `workspace` — absolute path to the directory where `code-ranker` was invoked @@ -622,7 +622,7 @@ subcommand at 10k nodes. - [x] `p1` - **ID**: `cpt-code-ranker-nfr-portability` JSON snapshot artifacts MUST conform to the Graph JSON Schema -(`schema_version: "3"`) and MUST be readable by the report generator and +(`schema_version: "4.0"`) and MUST be readable by the report generator and baseline comparison without migration within a major schema version. Generated HTML reports MUST open correctly in Chrome, Firefox, and Safari without installation. @@ -723,13 +723,13 @@ can render any language/metric set without hardcoding names. ```json { - "schema_version": "3", + "schema_version": "4.0", "generated_at": "<ISO-8601>", "command": "<full command line>", "workspace": "<absolute-path>", "target": "<absolute-path>", "plugin": "<plugin-id>", - "versions": { "code-ranker": "3.0.2", "rustc": "1.78.0" }, + "versions": { "code-ranker": "4.0.0-alpha.1", "rustc": "1.78.0" }, "roots": { "target": "<abs>", "registry": "<abs>" }, "git": { "branch": "main", "commit": "a3f9c21b4d5e", "dirty_files": 0, "origin": "git@…:team/proj.git" }, "timings": [ { "stage": "rust", "ms": 0, "detail": "…" }, … ], @@ -943,7 +943,7 @@ as a self-contained HTML report. snapshots; the verdict (`improved` / `degraded` / `neutral`) is present - [x] All P1 tools operate with zero outbound network calls - [x] Generated HTML reports contain no external resource references -- [x] JSON artifacts conform to the Graph JSON Schema (`schema_version: "3"`) +- [x] JSON artifacts conform to the Graph JSON Schema (`schema_version: "4.0"`) - [x] A `--baseline` comparison exits non-zero with a structured error on schema version mismatch - [ ] Every metric value equals the true count of what it measures — no false diff --git a/docs/code-ranker-cli/config.md b/docs/code-ranker-cli/config.md index c1295a98..a5fcfe9f 100644 --- a/docs/code-ranker-cli/config.md +++ b/docs/code-ranker-cli/config.md @@ -1,5 +1,19 @@ # code-ranker configuration +## Required `version` + +Every `code-ranker.toml` MUST declare the config-schema version it targets: + +```toml +version = "4.0" +``` + +It is the **config + CLI** format version (app `major.minor`). The loader rejects a +missing or mismatched value with a directional hint — an older version means +*migrate the config*, a newer one means *upgrade code-ranker* — instead of a cryptic +`unknown field` error. Bump it only when the config schema or CLI surface changes; +see [`../versions.md`](../versions.md) for the full versioning model and when to bump. + ## Priority order Settings are merged from multiple sources. **Higher priority wins** for the same key. diff --git a/docs/e2e.md b/docs/e2e.md index 1eb3d8c8..3ce8ce98 100644 --- a/docs/e2e.md +++ b/docs/e2e.md @@ -101,7 +101,7 @@ bumps; that is why a single shared version is safe. config (plugin pinned, `ignore.tests = false` to override the **on-by-default** test skipping so test files stay in the graph and the fixture exercises them). - `crates/code-ranker-plugins/src/<lang>/tests/sample/code-ranker-report.json` — the **golden** - JSON report (`schema_version: "3"`). The graph is already relativized to the + JSON report (`schema_version: "4.0"`). The graph is already relativized to the `{target}` placeholder (machine-independent). The header (`generated_at`, `command`, `git`, versions, absolute paths, `timings`) is kept frozen / anonymized in the committed file, and normalized only at comparison time. diff --git a/docs/node_schema.md b/docs/node_schema.md index e6274d5c..d553c362 100644 --- a/docs/node_schema.md +++ b/docs/node_schema.md @@ -1,7 +1,7 @@ # Node JSON Schema Reference for the node objects emitted in code-ranker snapshot files -(`.code-ranker/{ts}-{git-hash-3}.json`, `schema_version: "3"`), under +(`.code-ranker/{ts}-{git-hash-3}.json`, `schema_version: "4.0"`), under `graphs.files.nodes`. There is a single graph level — `files` — so every node is either a source `file` or a third-party `external` library. diff --git a/docs/versions.md b/docs/versions.md new file mode 100644 index 00000000..8e66441e --- /dev/null +++ b/docs/versions.md @@ -0,0 +1,107 @@ +# Versioning + +Code Ranker tracks **three independent versions**, each guarding one compatibility +surface and bumped on its own criterion. They are deliberately separate: the app +ships often, the on-disk formats rarely. Keeping them apart means upgrading the app +does **not** force a config migration unless the config format itself changed. + +| # | Surface | Constant | Lives in | Current | +|---|---------|----------|----------|---------| +| 1 | **app** — the release | `[workspace.package] version` (`env!("CARGO_PKG_VERSION")`) | root `Cargo.toml` | `4.0.0-alpha.1` | +| 2 | **config + CLI** — the user-facing input interface | `CONFIG_VERSION` | `crates/code-ranker-graph/src/version.rs` | `4.0` | +| 3 | **JSON snapshot + viewer** — the data format and its consumer | `SCHEMA_VERSION` | `crates/code-ranker-graph/src/version.rs` | `4.0` | + +Versions 2 and 3 are the app **`major.minor`** of the release that last changed +that surface (so a reader can tell which app generation a config/snapshot targets). +They may share a value — both `4.0` today — but move independently. The number +lives **only** at its constant; every consumer imports it, never hardcodes it +(fixtures and data files included — fixtures `format!` it from the constant). + +## 1. app version + +Plain SemVer of the release (`4.0.0-alpha.1`). Bumped with `make bump VERSION=…`, +which rewrites `Cargo.toml`, `README.md` and the `code-ranker`/`--version` doc +mentions. Every normal release bumps it; it does **not** imply a format change. + +## 2. config + CLI version — `CONFIG_VERSION` + +Governs the **TOML config schema** and the **CLI surface** (flags / subcommands / +output). A `code-ranker.toml` **must** declare a matching `version` +(`config::CONFIG_SCHEMA_VERSION` aliases `CONFIG_VERSION`); `config::load` rejects a +mismatch with a directional hint (older → migrate the config, newer → upgrade the +tool) instead of a cryptic `unknown field` error. + +**Bump when** the config schema or CLI surface changes incompatibly: + +- **minor** — additive / backward-compatible (a new optional config key, a new flag); +- **major** — breaking (a renamed/removed config key, flag, subcommand, or a changed + output contract). + +Set it to the app `major.minor` of the shipping release. Then update `version = "…"` +in the root `code-ranker.toml`, every sample (`crates/.../tests/sample/code-ranker.toml`), +and doc examples. + +## 3. JSON snapshot + viewer version — `SCHEMA_VERSION` + +Governs the **JSON snapshot shape** and the **viewer** that reads it. Written as the +snapshot's `schema_version`; a snapshot read back as `--baseline`/input is rejected +on mismatch (`analyze.rs`), and the viewer rejects an incompatible swapped-in +snapshot (the renderer injects `window.SCHEMA_VERSION`, checked in +`snap-controls.js`). + +**Bump when** the snapshot JSON changes incompatibly (a field added/renamed/removed, +or the viewer's read contract changes) — same **minor / major** rule as +`CONFIG_VERSION`, set to the app `major.minor` of the shipping release. Then +regenerate the e2e goldens (their `schema_version`). + +## When to bump — branch discipline + +A format version represents "the format as of release X". A single unmerged branch +becomes a single release, so it must move each format version **at most once** — +regardless of how many commits touch that format. The branch's net effect on a +surface is what matters, not each step along the way. + +### Procedure (per surface, per branch) + +1. **Detect a change.** Compare the branch against `main` for that surface: + - **config + CLI** (`CONFIG_VERSION`) — did the `code-ranker.toml` schema or the + clap flags / subcommands / output shape change? (`git diff main...HEAD` over + `crates/code-ranker-cli/src/cli.rs`, `config/`, and the config docs.) + - **JSON snapshot + viewer** (`SCHEMA_VERSION`) — did the snapshot JSON shape or + the viewer's read contract change? (diff over `crates/code-ranker-graph/src/{snapshot,serialize}.rs`, + the `node_attributes`/`edge_kinds`/… emitted, and `crates/code-ranker-viewer/`.) + - No change to a surface → **do not** touch its version, even if the app bumped. +2. **Classify severity** (the *net* change vs `main`): + - **minor** — additive / backward-compatible: a new **optional** config key, a + new flag, a new optional JSON field. Old configs/snapshots still load. + - **major** — breaking: a renamed/removed config key, flag, subcommand, JSON + field, or a changed meaning/output contract. Old inputs no longer load. +3. **Bump once.** Set the constant to the app `major.minor` of the release this + branch will ship as. Then propagate (see each surface's section above: + `version = …` in configs / samples / doc examples; regenerate goldens' + `schema_version`). + +### Don't stack — escalate + +If the surface was **already bumped earlier in this same branch** (vs `main`): + +- another change of the **same or lower** severity → **no** new bump; it rides the + existing one (a second additive tweak is still just one minor step for the + release). +- a **breaking** change after a **minor** bump → **escalate**: replace the minor + with a **major** (e.g. `4.0 → 4.1` becomes `4.0 → 5.0`). Never end with two + separate bumps in one branch. + +### Worked examples + +- Branch adds one optional `[rules]` key → `CONFIG_VERSION` minor (`4.0 → 4.1`). + `SCHEMA_VERSION` untouched (JSON unchanged). +- Same branch later renames a snapshot field → `SCHEMA_VERSION` **major** + (`4.0 → 5.0`); the earlier `CONFIG_VERSION` minor stays as-is (different surface). +- Branch first adds an optional flag (`CONFIG_VERSION` `4.0 → 4.1`), then removes a + different flag → the removal is breaking, so escalate **the same** bump to + `CONFIG_VERSION` `4.0 → 5.0` (not a separate second bump). +- Branch only refactors internals / fixes a bug with no format change → bump + **nothing** here (the app version still moves on release, per §1). + +The `/update-docs` checklist runs this procedure as its format-compatibility step. From c0d8a90126596e1ad77d5e1740778f8c25a650ee Mon Sep 17 00:00:00 2001 From: Roman Fedorov <Roman.Fedorov@constructor.tech> Date: Tue, 23 Jun 2026 11:16:16 +0300 Subject: [PATCH 04/40] docs(prd): drop JSON-schema and plugin internals (business-level PRD) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The product PRD should carry no on-disk-format or implementation detail: - §5.1 Snapshot File Format: drop the JSON example + field-by-field spec; keep a business note pointing at node_schema.md / DESIGN.md for the shape. - §7.3 Graph JSON Schema: removed (the JSON contract lives in node_schema.md). - §7.2 Plugin Model: replaced the trait / inventory / metric-pipeline internals with a business description (supported languages, --plugin, in-process/offline, no external loading); the mechanism is in DESIGN.md. - Repointed the node_schema.md cross-refs that named the removed PRD §7.3. JSON-shape and internals now live solely in node_schema.md / DESIGN.md. --- docs/PRD.md | 241 +------------------------------------------- docs/node_schema.md | 7 +- 2 files changed, 6 insertions(+), 242 deletions(-) diff --git a/docs/PRD.md b/docs/PRD.md index 43c5a735..5e064544 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -24,7 +24,6 @@ - [7. Public Interfaces](#7-public-interfaces) - [7.1 Code Ranker Unified CLI](#71-code-ranker-unified-cli) - [7.2 Plugin Model](#72-plugin-model) - - [7.3 Graph JSON Schema](#73-graph-json-schema) - [8. Use Cases](#8-use-cases) - [UC-001 Analyze Rust Workspace Offline](#uc-001-analyze-rust-workspace-offline) - [UC-002 Before/After Refactoring Comparison](#uc-002-beforeafter-refactoring-comparison) @@ -254,81 +253,7 @@ required. - [x] `p1` - **ID**: `cpt-code-ranker-fr-snapshot-meta` -Each `code-ranker report` run produces a single `.json` file -(`schema_version: "4.0"`). The file combines metadata and the `graphs` map (one -entry per analysis level — today only `files`) in one document. Each level -bundles its semantics dictionaries with the structural graph and computed data -(see §7.3 for the full shape): - -```json -{ - "schema_version": "4.0", - "generated_at": "2026-05-22T11:22:33Z", - "command": "code-ranker report /path/to/axum-api --plugin rust", - "workspace": "/Users/alice/projects/code-ranker", - "target": "/Users/alice/projects/axum-api", - "plugin": "rust", - "config_file": "/Users/alice/projects/axum-api/code-ranker.toml", - "versions": { "code-ranker": "4.0.0-alpha.1", "rustc": "1.78.0" }, - "roots": { - "registry": "/Users/alice/.cargo/registry/src/index.crates.io-abc123", - "target": "/Users/alice/projects/axum-api" - }, - "git": { - "branch": "refactor/split-handlers", - "commit": "a3f9c21b4d5e", - "dirty_files": 4, - "origin": "git@gitlab.example.com:team/axum-api.git" - }, - "timings": [ - { "stage": "rust", "ms": 600, "detail": "547 nodes from 512 files" }, - { "stage": "complexity", "ms": 700, "detail": "512 nodes annotated" }, - { "stage": "projection", "ms": 5, "detail": "nodes=550 edges=1320" } - ], - "graphs": { - "files": { - "edge_kinds": { ... }, "node_attributes": { ... }, - "edge_attributes": { ... }, "attribute_groups": { ... }, - "nodes": [...], "edges": [...], "cycles": [...], "stats": { ... } - } - } -} -``` - -Top-level fields: - -- `schema_version` — `"4.0"` (the generic property-graph format) -- `generated_at` — ISO-8601 timestamp -- `command` — full command line as typed -- `workspace` — absolute path to the directory where `code-ranker` was invoked -- `target` — absolute path to the analyzed project -- `plugin` — resolved built-in plugin name (`rust` / `python` / `javascript` / `typescript` / `go`) -- `config_file` — absolute path of the config file used; omitted when none was found -- `versions` — `code-ranker` semver at minimum; the Rust plugin adds `rustc` -- `roots` — named system prefixes used to relativize node ids/paths - (`roots[name] + "/" + rest` → absolute path). Roots that did not shorten any - path are pruned, so a JS/TS/Python snapshot carries only `{target}` and a Rust - snapshot `{target}` + `{registry}` -- `git` — `branch`, `commit` (12-char short SHA), `dirty_files`, and `origin`; - the whole block omitted if not a git repository. Each field is read from `git` - but can be overridden with a `--git.<field>` flag (for CI, where a detached - checkout otherwise reports the branch as `HEAD` and job-written files inflate - the dirty count); when `branch`, `commit`, and `dirty-files` are all supplied, - `git` is not invoked at all -- `timings` — per-stage wall-clock timings (`stage`, `ms`, `detail`), in - execution order; omitted when empty -- `graphs` — a map `level_name → level`. `files` is always present; `functions` - (per-function metric nodes) is present when `[levels] functions` is enabled. Each - level carries the four semantics dictionaries (`edge_kinds`, - `node_attributes`, `edge_attributes`, `attribute_groups`) plus `nodes`, - `edges`, `cycles`, `stats`, and a computed `ui` block (column/sort/size order - and an optional `grouping` telling the viewer how to cluster nodes — e.g. - `{ "key": "crate" }`) -- `principles` — the Prompt-Generator principle catalog (`id` / `title` / `prompt` / - `sort_metric` / `connections` / …), language-adapted; omitted when empty -- `prompt` — the language-neutral Prompt-Generator **scaffolding** (`intro` / - `doc_note` / `task` / `focus` / `cycle_note`), so the CLI `prompt` format and - the HTML viewer render the same prompt from one source +Each `code-ranker report` run produces a single self-contained `.json` snapshot — run metadata (when, how, tool/plugin versions, git state) combined with the analyzed graph and its computed metrics, one file per run. One file is easy to copy, archive, and diff. Its exact field-level shape is a technical contract documented in [`node_schema.md`](node_schema.md) and [`DESIGN.md`](DESIGN.md), not duplicated in this product document. `code-ranker report` and `code-ranker check` (with `--baseline`) read snapshot files and embed the top-level metadata in the generated HTML as a @@ -675,169 +600,7 @@ requires every count to mean exactly what it claims. **Stability**: unstable (pre-1.0) -Plugins are compiled into the `code-ranker` binary and run **in-process** -when a command analyzes a workspace (`code-ranker check` / `code-ranker -report`). The plugins are `rust`, `python`, `javascript`, `typescript`, `go`, -`c`, `cpp`, `csharp`, and `markdown`, -selected with `--plugin <name>` (see `cpt-code-ranker-fr-plugin-discovery`). -There is no subprocess invocation, no external plugin binary, and no -external/dynamic plugin loading. - -Each plugin implements the `LanguagePlugin` trait (`code-ranker-plugin-api`) as a -**pure parser**: `analyze(workspace, input)` returns a structural `Graph` -(nodes + edges, **no metrics**), and `levels()` declares the level's semantics -dictionaries. When `input.ignore_tests` is set (`[ignore] tests`, **on by -default**), the plugin skips its own test files during the walk — what counts as -a test is language-specific (Rust `#[cfg(test)]` modules, Python -`test_*.py`/`tests/`, JS/TS `*.test.*`/`__tests__`), so the detection lives in -the plugin (inside `analyze`), not the CLI. The directory-walking plugins -also honour `.gitignore` / `.ignore` / hidden files while collecting sources -(`[ignore] gitignore` / `ignore_files` / `hidden`, **all on by default**, scoped -to the analyzed root); the Rust plugin resolves files via `cargo metadata`, so it -is unaffected. Per-language complexity -metrics are measured by the plugin's `metrics()` step (running the matching -in-tree language engine — no central by-extension dispatcher) and written -centrally by the orchestrator via `code_ranker_graph::write_metrics`, like the -language-agnostic derived data (cycles / Henry-Kafura / stats in -`code-ranker-graph` over the level's flow edges); all are -written into node attributes by id, and the orchestrator assembles the snapshot. -Adding a language means adding a built-in plugin module (implementing `analyze` + -`metrics`) that **self-registers** with one `inventory::submit!` — the binary -collects every plugin via `code_ranker_plugin_api::registry()`, so there is no -central list to edit. - -### 7.3 Graph JSON Schema - -- [x] `p1` - **ID**: `cpt-code-ranker-interface-graph-schema` - -**Type**: Data format (JSON) - -**Stability**: unstable (pre-1.0) - -A **generic property graph**: free-form string `kind` on nodes and edges, and a -flat free-form attribute map on each. No fixed enums, no nested metric objects. -Each level carries semantics dictionaries describing its vocabulary so a consumer -can render any language/metric set without hardcoding names. - -**Top-level shape** (full snapshot file): - -```json -{ - "schema_version": "4.0", - "generated_at": "<ISO-8601>", - "command": "<full command line>", - "workspace": "<absolute-path>", - "target": "<absolute-path>", - "plugin": "<plugin-id>", - "versions": { "code-ranker": "4.0.0-alpha.1", "rustc": "1.78.0" }, - "roots": { "target": "<abs>", "registry": "<abs>" }, - "git": { "branch": "main", "commit": "a3f9c21b4d5e", "dirty_files": 0, "origin": "git@…:team/proj.git" }, - "timings": [ { "stage": "rust", "ms": 0, "detail": "…" }, … ], - "graphs": { - "files": { - "edge_kinds": { "<kind>": { "flow": true, "label": "…", "description": "…" } }, - "node_attributes": { "<key>": { "value_type": "int|float|str|bool", "label": "…", - "name": "…", "short": "…", "description": "…", - "formula": "…", "calc": "<eval expr>", - "direction": "higher_better|lower_better", - "abbreviate": true, "group": "<group?>", - "thresholds": { "info": N, "warning": N } } }, - "edge_attributes": { "<key>": { "value_type": "…", "label": "…" } }, - "attribute_groups": { "<group>": { "label": "…", "description": "…" } }, - "node_kinds": { "<kind>": { "label": "…", "plural": "…", "fill": "#…", "stroke": "#…", "external": true } }, - "cycle_kinds": { "<kind>": { "label": "…", "description": "…" } }, - "ui": { "default_sort": "…", "sort": [...], "size": [...], - "card": [...], "columns": [...], "summary": [...] }, - "nodes": [...], "edges": [...], "cycles": [...], "stats": { ... } - } - }, - "principles": [ { "id": "ADP", "label": "ADP", "title": "…", "prompt": "…", - "doc_url": "…", "sort_metric": "cycle", "connections": ["common","out"] } ] -} -``` - -`graphs` is a map `level_name → level` (`files`, plus `functions` when enabled). -The dictionaries are pruned to the keys/kinds/groups actually present at that -level, and the `ui` block is computed by the orchestrator from the present -attributes. Every metric's label / name / formula / live-`calc` / direction / -threshold lives in `node_attributes`, and the Prompt-Generator principles live in -top-level `principles`, so the **viewer hardcodes no metric, kind, threshold or -prompt by name** — it renders entirely from this data (see DESIGN §3.2 HTML -assets). Optional `AttributeSpec` fields are omitted when absent. - -Tier-2 metrics are **declarative data**, not hardcoded computation: each is a CEL -`formula_cel` plus spec in `code-ranker-graph/metrics/builtin.toml`, evaluated by the -registry engine over the per-language tier-1 counts. A user adds or overrides -metrics under `[metrics.<key>]` in config (node-scope per-unit formulas, or -graph-scope `agg(…)` aggregates emitted into `stats`) with no code change; only -tier-1 counting and the graph algorithms (`fan_in`/`fan_out`/`cycle`) are in Rust. -A project config can also surface those metrics in the report (`[report]` column / -card / stats list-overrides), gate `check` on them (`[rules.thresholds.file]`, -custom metrics included), and add Prompt-Generator lenses (`[principles.<ID>]`) — all -data, no code. See `docs/code-ranker-cli/config.md` and `docs/customization/`. - -**Node shape** — `id`, `kind`, `name`, optional `parent`, plus flat attributes: - -```json -{ "id": "{target}/src/foo.rs", "kind": "file", "name": "foo.rs", - "visibility": "public", "loc": 48, "sloc": 36, "lloc": 12, "cloc": 4, "blank": 6, "tloc": 2, - "cyclomatic": 3, "cognitive": 2, "exits": 2, "args": 3, "closures": 1, "unsafe": 1, - "eta1": 14, "eta2": 9, "n1": 52, "n2": 35, "spaces": 2, "branches": 1, "span_sloc": 40, - "mi": 78.4, "mi_sei": 52.1, "length": 87, "vocabulary": 23, "volume": 312.5, - "effort": 4820, "time": 267.8, "bugs": 0.104, - "fan_in": 4, "fan_out": 2, "fan_out_external": 1, "hk": 1344, "cycle": "mutual" } -``` - -```json -{ "id": "ext:serde", "kind": "external", "name": "serde", - "external": true, "version": "1.0.228", "path": "{registry}/serde-1.0.228" } -``` - -`kind` is `"file"` (a project source file — **its id IS its relativized path**, -no `file:` prefix, and it carries no `path` attribute) or `"external"` (a -3rd-party library, id `ext:<name>`, marked `external: true`; for Rust it also -carries `version` and `path` = the crate's cargo-cache directory). All -attributes are **flat** and a metric is **omitted when it rounds to zero** — -empty / default values are not stored, in both the JSON and the HTML viewer -(see `docs/DESIGN.md` for the gating rules and their one exception). Numeric -values use 3-significant-digit rounding; integral values serialize without a -decimal point. -`fan_in` / `fan_out` / `hk` count internal flow edges -only; edges whose target is external are counted in `fan_out_external`. `cycle` -(`"mutual"` / `"chain"`) is present only on nodes in a cycle. `unsafe` (Rust-only) -is a per-file count of `unsafe` usages, present only when non-zero. - -**Edge shape**: - -```json -{ "source": "<node-id>", "kind": "uses | reexports | contains | super", "target": "<node-id>", "line": 12 } -``` - -An edge is **external iff its `target` is an `ext:` node** — there is no -`edge.external` flag. Whether an edge kind is information flow vs. structural is -read from `edge_kinds[kind].flow` (e.g. `contains` is `flow: false` — kept and -shown as ownership, excluded from fan_in / HK / cycles). Edge attributes (e.g. a -Rust `reexports` edge's `visibility`) are flattened in alongside `source` / -`kind` / `target`. `line` is the optional 1-based line of the declaring -`use` / `import` statement (omitted for `contains` and unplaceable edges); `check` -uses it to point a cycle violation at a concrete edge to break. - -**Stats shape** (`stats` field on a level) — a flat map of the mean of each -tracked numeric metric across the level's file nodes (zero/missing excluded; a -metric emitted only when its average is positive), e.g.: - -```json -{ "cyclomatic": 1.4, "cognitive": 1.8, "fan_in": 2.25, "fan_out": 3, "hk": 864, - "mi": 104.0, "mi_sei": 105.7, "sloc": 15.8, "cloc": 3.8, "blank": 6.8, "tloc": 4.2, - "length": 32.2, "vocabulary": 19.6, "volume": 149.1, "effort": 1030.4, - "time": 57.2, "bugs": 0.029 } -``` - -Percentiles are not stored — a viewer can compute them client-side from raw node -data. - -**Breaking Change Policy**: Additive fields are minor; renames or -removals require a major-version bump and migration notes. +Each supported language — `rust`, `python`, `javascript`, `typescript`, `go`, `c`, `cpp`, `csharp`, `markdown` — has a built-in analyzer, selected with `--plugin <name>` (see `cpt-code-ranker-fr-plugin-discovery`). Analyzers run **in-process and offline**: no subprocess, no external plugin binary, and no external/dynamic plugin loading, so a run needs nothing beyond the `code-ranker` binary. Test files are skipped by default (language-specific detection) and `.gitignore`/hidden files are honoured. Adding a language is an internal change to the binary; the analyzer contract, metric pipeline and registration mechanism are documented in [`DESIGN.md`](DESIGN.md), not in this product document. ## 8. Use Cases diff --git a/docs/node_schema.md b/docs/node_schema.md index d553c362..35c7e89e 100644 --- a/docs/node_schema.md +++ b/docs/node_schema.md @@ -12,7 +12,8 @@ a `name`, and a **flat attribute map** (no nested `complexity` / `coupling` / `description` (the diagnostic *why*) and `remediation` (the diagnostic *fix*) are described by the level's `node_attributes` dictionary, so a consumer can render any metric without -hardcoding it — see the main [DESIGN](DESIGN.md) §3.1/§3.7 and [PRD](PRD.md) §7.3. +hardcoding it — see the main [DESIGN](DESIGN.md) §3.1/§3.7 (this doc is the full +JSON-shape reference). (`check`'s `why` / `fix` lines are read from these spec fields — and, for cycle rules, from the level's `cycle_kinds` `description` / `remediation` — so no rule prose is hardcoded in the CLI.) @@ -313,7 +314,7 @@ violation at a concrete spot to break (see the `github` / `sarif` annotations in --- -**Related docs**: [PRD.md](PRD.md) §7.3 (the full Graph JSON Schema) · -[DESIGN.md](DESIGN.md) §3.1 Domain Model / §3.7 Snapshot File Format. The schema +**Related docs**: [DESIGN.md](DESIGN.md) §3.1 Domain Model / §3.7 Snapshot File +Format (the product [PRD.md](PRD.md) keeps no JSON details). The schema is defined by the `Node` / `Edge` structs in `crates/code-ranker-plugin-api/src/` and the `Snapshot` / `LevelGraph` structs in `crates/code-ranker-graph/src/`. From 555f561a23a7cafedf3cfd484241f6026ee17292 Mon Sep 17 00:00:00 2001 From: Roman Fedorov <Roman.Fedorov@constructor.tech> Date: Tue, 23 Jun 2026 13:09:20 +0300 Subject: [PATCH 05/40] docs: add prompt self-improvement loop playbook --- docs/prompting-self-improve.md | 214 +++++++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 docs/prompting-self-improve.md diff --git a/docs/prompting-self-improve.md b/docs/prompting-self-improve.md new file mode 100644 index 00000000..e8b87971 --- /dev/null +++ b/docs/prompting-self-improve.md @@ -0,0 +1,214 @@ +# Prompt self-improvement loop + +A repeatable way to **empirically tune the AI fix-prompts** so that *cheaper* +models still produce reference-quality fixes. The reference is the most capable +model; the goal is to lift each cheaper tier up to it by improving the prompt — +not by relying on the model. + +Think of it as a function: + +``` +improve(PROJECT, FOCUS) # sweeps models, iterates the prompt +``` + +## Inputs (the variables) + +| Variable | Meaning | Examples | +|---|---|---| +| `MODEL` | the agent model under test, ordered **most → least** capable | `opus` → `sonnet` → `haiku` | +| `FOCUS` | what to fix — a principle **or** a metric, passed to `--focus` | `cycle` (ADP), `hk`, `sloc`, `cognitive`, `SRP`, … | +| `PROJECT` | an **external** repo (not code-ranker) with real, non-trivial instances of `FOCUS` | any sample/work repo | + +`MODEL_REF` = the first (most capable) model — the quality bar every cheaper model +is measured against. + +## What we tune (the levers) + +The prompt an agent sees is assembled from **embedded data**. To change it, edit one +of these and rebuild (see Setup) — all are baked into the binary: + +- **principle framing** — the `[[principles]]` `prompt` in + `crates/code-ranker-plugins/src/defaults.toml` (+ per-language overrides in + `crates/code-ranker-plugins/src/languages/<lang>/config.toml`). +- **scaffolding** (intro / doc-note / task / focus prose) — + `crates/code-ranker-graph/metrics/prompt.md`. +- **the full reference doc** the agent reads via `--doc <FOCUS>` — + `languages/<lang>/<FOCUS>.md` (e.g. `ADP.md`), and the offline entry point + `languages/base/AI.md` (`--doc AI`). + +Change the **smallest** lever that fixes the observed failure. + +## Setup (once per prompt version) + +- **S1 — fresh build on PATH.** Release-build and install locally so the + `code-ranker` invoked by the agent is the current build: + `cargo build --release` (then `cargo install --path crates/code-ranker-cli`). +- **S2 — provenance commit + run id.** Commit code-ranker, so every report this + build generates carries the current version + commit + date. Then capture the + **short hash** — `CR_SHA=$(git -C <code-ranker> rev-parse --short HEAD)`. It names + the artifact directory for this build (next section): every chat, report and JSON + is traceable to the exact build — i.e. the exact **prompt version** — that + produced it. + +Every prompt edit (a lever above) re-runs S1–S2 before the next sweep, yielding a +fresh `CR_SHA` → a fresh artifact directory. + +## The algorithm + +``` +for MODEL in models (most → least capable): # opus, then sonnet, then haiku… + loop (≤ 3 times): + R = run(PROJECT, FOCUS, MODEL) # one clean-context fix (below) + save artifacts(R) + score R against MODEL_REF's best run for FOCUS + if R ≈ reference quality: + break # this tier is good — lock it + else: + tune a prompt lever to address R's failure + rebuild (S1–S2) + # descend to the next cheaper model and re-verify with the improved prompt +``` + +End state: the **cheapest** tier still produces reference-quality fixes for `FOCUS`. +Then repeat `improve(...)` for the next `FOCUS`. + +## A single run — `run(PROJECT, FOCUS, MODEL)` + +Let `RUN=<code-ranker>/.code-ranker/prompt-eval/<timestamp>_<CR_SHA>/<MODEL>-<FOCUS>-<N>` +— an **absolute** path into *this* repo's `.code-ranker/` (create it first). The +agent runs `code-ranker report .` inside `PROJECT`, but every `--output.*.path` +points at `$RUN`, so the evidence lands in code-ranker, not `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 report --doc AI` (overview + catalog) and `--doc <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`. +4. **Fix.** Save the focused prompt and hand it to the agent: + `code-ranker report . --output.prompt.path=$RUN/prompt.md --focus <FOCUS> --top 1`. + The agent proposes the plan, applies the fix, runs the project's tests. +5. **AFTER + DIFF.** `code-ranker report . --baseline $RUN/before.json --output.html.path=$RUN/diff.html --output.json.path=$RUN/after.json` (+ an `after.html`). +6. **Save the transcript** to `$RUN/chat.md` (see "Saving the chat"), commit the code + change to branch `<MODEL>-<FOCUS>-<N>` in `PROJECT`, return to `main`. + +## Artifacts: layout & naming + +Everything lives under the **code-ranker repo's own `.code-ranker/`** (this repo, +not `PROJECT`) — it's gitignored and is the project's keep-forever run area, so all +prompt-eval evidence is collected in one place across every `PROJECT` and model. The +external `PROJECT` only carries the **code change**, on its branch. All evidence for +one **build / prompt version** sits in a single dated folder; **keep everything — +never delete, the runs are the comparison corpus.** + +Layout (one build → one `<timestamp>_<CR_SHA>` folder → one subfolder per run): + +``` +<code-ranker>/.code-ranker/ # THIS repo's dir, not PROJECT's +└─ prompt-eval/ + └─ 20260623T1412Z_a660e36/ dir — <UTC timestamp>_<CR_SHA from S2> + ├─ run.md md ~1 KB inputs: project, FOCUS, models, cr version+commit + ├─ results.md md ~2 KB the results-log rows for this build + ├─ opus-cycle-1/ dir one run = <model>-<focus>-<n> (matches the PROJECT branch) + │ ├─ before.json json ~150 KB baseline snapshot + │ ├─ before.html html ~1.5 MB self-contained viewer (inlined WASM/assets) + │ ├─ after.json json ~150 KB post-fix snapshot + │ ├─ after.html html ~1.5 MB + │ ├─ diff.html html ~1.6 MB baseline↔current diff report + │ ├─ prompt.md md ~3 KB the exact `--focus` fix-prompt the agent got + │ ├─ chat.jsonl jsonl ~0.5–3 MB raw session record (Claude Code; verbatim) + │ └─ chat.md md ~50–300 KB readable transcript (the tuning data) + ├─ sonnet-cycle-1/ dir same shape + └─ haiku-cycle-2/ dir same shape +``` + +- folder/run id = `<model>-<focus>-<n>`, identical to the PROJECT branch holding + that run's code change — so evidence ↔ code line up by name. +- the code-ranker version/commit is also embedded *inside* each report (from S2), so + a file stays self-describing even if moved out of its folder. +- HTML reports are large (self-contained, WASM inlined); JSON snapshots scale with + the project; `chat.md` is the biggest signal-per-byte and the smallest to diff. + +### Launching a clean-context agent + +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 `--doc AI` primes the agent; otherwise you're testing the +priming, not the prompt. + +- **Claude Code** (Opus / Sonnet / Haiku), interactive — what the fix loop wants + (multi-turn: run code-ranker, edit, run tests): + + ```sh + cd PROJECT # external repo, on main, clean tree + claude --model opus # or sonnet / haiku — pins the tier; fresh = no context + ``` + + Then give it **one** opening message (the bootstrap), nothing else: + + > Read `code-ranker report --doc AI`, then fix the worst `<FOCUS>` 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 report --doc AI\`, then fix the worst <FOCUS>…" --model haiku + ``` + +- **Other agents** (Cursor, …): open a **New Chat** (not a continued thread), select + the model, paste the same one-message bootstrap. + +### Saving the chat + +The transcript is the **primary tuning data** — it shows *where* a cheaper model +diverged (skipped `--doc`, picked the wrong cycle, hacked the metric). Save it raw, +**verbatim, no summary**, into `$RUN/chat.*`. It must include the bootstrap +(`--doc AI` / `--doc <FOCUS>` 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. + +- **Claude Code** — the canonical record is the session **JSONL** at + `~/.claude/projects/<cwd-slug>/<session-id>.jsonl` (cwd-slug = `PROJECT`'s path with + `/`→`-`; one file per session, newest by mtime = the run you just did). Copy it to + `$RUN/chat.jsonl` (verbatim turns + tool calls) and/or render it to `$RUN/chat.md` + for reading. +- **Other agents**: export / copy the conversation as Markdown into `$RUN/chat.md`. +- Also save the exact fix-prompt the agent received as `$RUN/prompt.md`, so prompt → + behaviour is correlatable across models. Markdown stays readable and diffable. + +## Comparison & scoring + +Score each cheaper-model run against `MODEL_REF`'s run for the same `FOCUS`: + +| Signal | Source | Question | +|---|---|---| +| **Correctness** | project tests | Tests pass, behaviour preserved? | +| **FOCUS reduced** | `diff.json` verdict + metric delta | Fewer cycles / lower HK / …? (objective) | +| **Structural quality** | transcript + diff | A real fix (extract / invert / split), not a hack to silence the metric? | +| **Followed the prompt** | transcript | Read the doc, proposed before changing, took before/after reports? | +| **Cost** | transcript | Turns / tokens to get there. | + +The diff verdict + delta are the **objective** signal; the transcript is the +**qualitative** "why" that drives the prompt change. + +## Tuning rule + +A prompt change is justified only when a cheaper model fails in a way the prompt +*could* have prevented — e.g. it skipped the reference doc, picked the wrong cycle, +or hacked the metric instead of extracting an abstraction. Map the failure to the +**smallest** lever (principle `prompt` ⊂ scaffolding ⊂ the `<FOCUS>` doc), change +only that, rebuild, re-sweep. Avoid over-fitting to one project: a change should +help the failure class, not memorise the repo. + +Stop a tier after **3 iterations** even if not perfect — record the residual gap so +it's a decision on record, not a silent failure. + +## Results log + +Track one row per run so the sweep is auditable: + +| date | cr version+commit | PROJECT | FOCUS | MODEL | iter | branch | verdict (Δ) | tests | quality 1–5 | notes / failure class | +|------|-------------------|---------|-------|-------|------|--------|-------------|-------|-------------|----------------------| +| … | 4.0.0-alpha.1 @abc123 | … | cycle | opus | 1 | opus-cycle-1 | improved (−2 cycles) | pass | 5 | reference | +| … | 4.0.0-alpha.1 @abc123 | … | cycle | sonnet | 1 | sonnet-cycle-1 | neutral (0) | pass | 2 | skipped `--doc`, hacked one edge | From 45f392ac03916781bb1dfc2aeae14de5eba0f953 Mon Sep 17 00:00:00 2001 From: Roman Fedorov <Roman.Fedorov@constructor.tech> Date: Tue, 23 Jun 2026 15:27:07 +0300 Subject: [PATCH 06/40] feat(cli): add offline `ai` command; drop `docs` corpus publishing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the maintenance-only `docs` subcommand (and its GitHub Pages corpus step) with a project-facing `ai` command, and stop tests littering .code-ranker/. - remove `docs` subcommand + `templates::build_corpus` + the pages.yml corpus-compose step; the corpus stays binary-embedded (`--doc <ID>`), no longer served over a URL. The HTML report publish in pages.yml stays. - add `ai`: prints the offline AI-agent playbook (`base/AI.md`), no analysis, always exits 0. Resolves the language plugin only to pick output — full playbook + principle/metric catalog when resolved; a brief intro + a dynamic 'Select a language' section (template authored in AI.md, values filled by `ai::fill_select`) when none resolves, with the catalog withheld. - AI.md: expanded product description + a Commands section (check/report/ai/help). - e2e: run report-emitting helpers from a temp cwd so the default json+html pair (always written, since the built-in [output.*] paths are set) no longer lands in the repo's own .code-ranker/. - docs: CLI.md/PRD/DESIGN/ai-skill/templates.md synced. No version bump: the removed/added CLI surface rides this branch's existing CONFIG_VERSION 4.0 (already a major vs main 3.0.2). --- .github/workflows/pages.yml | 8 -- crates/code-ranker-cli/src/ai.rs | 50 ++++++++ crates/code-ranker-cli/src/ai_test.rs | 41 +++++++ crates/code-ranker-cli/src/cli.rs | 28 +++-- crates/code-ranker-cli/src/main.rs | 12 +- crates/code-ranker-cli/src/templates.rs | 88 +++++++------- crates/code-ranker-cli/src/templates_test.rs | 93 ++++++++++++--- crates/code-ranker-cli/tests/e2e.rs | 117 ++++++++++++++----- docs/ai-skill.md | 9 ++ docs/code-ranker-cli/CLI.md | 35 +++++- docs/code-ranker-cli/DESIGN.md | 17 ++- docs/code-ranker-cli/PRD.md | 14 ++- docs/templates.md | 25 ++-- languages/base/AI.md | 66 +++++++++-- 14 files changed, 464 insertions(+), 139 deletions(-) create mode 100644 crates/code-ranker-cli/src/ai.rs create mode 100644 crates/code-ranker-cli/src/ai_test.rs diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 63baab0e..51615af7 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -48,14 +48,6 @@ jobs: --git.origin="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}" \ --output.html.path=site/index.html \ --output.json.path=site/report.json - - name: Compose the principle/metric doc corpus into ./site - # base/<ID>.md (fallback) + composed <lang>/<ID>.md (base ⊕ overlay), so a - # finding's `doc_url` resolves on Pages once `doc_base` points here. - # `.nojekyll` makes Pages serve the raw Markdown (Jekyll would render it), - # which is what the `remediation` "Download …" links and LLMs want. - run: | - cargo run -q -p code-ranker -- docs --out site - touch site/.nojekyll - uses: actions/configure-pages@v5 - uses: actions/upload-pages-artifact@v5 with: diff --git a/crates/code-ranker-cli/src/ai.rs b/crates/code-ranker-cli/src/ai.rs new file mode 100644 index 00000000..ea93f0c6 --- /dev/null +++ b/crates/code-ranker-cli/src/ai.rs @@ -0,0 +1,50 @@ +//! The `ai` subcommand: print the offline AI-agent playbook to stdout. +//! +//! Unlike `check` / `report` it never analyzes — it only resolves which language +//! plugin applies (explicit `--plugin` > config `plugin` > auto-detect from the +//! `[input]` directory's markers) to choose the output: +//! - **resolved** → the full embedded `base/AI.md` playbook + principle/metric +//! catalog (the agent can analyze, so no plugin-setup noise); +//! - **unresolved** (no marker, or ambiguous markers) → a brief product intro plus +//! how to select a plugin, with the catalog withheld until a language is chosen. + +use anyhow::Result; +use code_ranker_graph::version::CONFIG_VERSION; +use std::path::Path; + +use crate::{config, plugin, templates}; + +pub(crate) fn run(input: &Path, plugin_arg: Option<&str>, config_entries: &[String]) -> Result<()> { + // `ai` is a doc command: a missing or broken config must not fail it. Read the + // config best-effort, only for its `plugin` key. + let cfg_plugin = config::load(input, config_entries, &[], &[], &[]) + .ok() + .and_then(|loaded| loaded.config.plugin); + + let md = match plugin::resolve_plugin(plugin_arg, cfg_plugin.as_deref(), input) { + Ok(_) => templates::ai_doc()?, + Err(reason) => fill_select(&templates::ai_doc_intro()?, &reason.to_string()), + }; + + print!("{md}"); + if !md.ends_with('\n') { + println!(); + } + Ok(()) +} + +/// Fill the *Select a language* template authored in `base/AI.md` (returned in the +/// intro by [`templates::ai_doc_intro`]) with the live values: the resolver +/// diagnostic (`reason` — no marker / ambiguous markers), the built-in plugin names, +/// and the config-schema version. The prose lives in the doc; only the values are +/// injected here. +fn fill_select(intro: &str, reason: &str) -> String { + intro + .replace("{reason}", reason) + .replace("{plugins}", &plugin::names()) + .replace("{config_version}", CONFIG_VERSION) +} + +#[cfg(test)] +#[path = "ai_test.rs"] +mod tests; diff --git a/crates/code-ranker-cli/src/ai_test.rs b/crates/code-ranker-cli/src/ai_test.rs new file mode 100644 index 00000000..f22625bd --- /dev/null +++ b/crates/code-ranker-cli/src/ai_test.rs @@ -0,0 +1,41 @@ +use super::*; + +#[test] +fn fill_select_injects_live_values_into_the_doc_template() { + let reason = "ambiguous project in .: markers for multiple plugins found (rust, markdown) — pass --plugin to choose"; + let md = fill_select(&templates::ai_doc_intro().unwrap(), reason); + + // Intro + command list (the prose authored in base/AI.md) is kept… + assert!( + md.contains("code-ranker — AI agent skill"), + "intro head present" + ); + assert!( + md.contains("## Commands") && md.contains("**`help`**") && md.contains("**`report"), + "command list present" + ); + // …and the Select-a-language template is filled with live values. + assert!(md.contains("## Select a language"), "setup section present"); + assert!( + md.contains(reason), + "{{reason}} replaced with the diagnostic" + ); + assert!( + md.contains(&plugin::names()), + "{{plugins}} replaced with the registry names" + ); + assert!( + md.contains(&format!("version = \"{CONFIG_VERSION}\"")), + "{{config_version}} replaced with the live CONFIG_VERSION" + ); + + // No placeholder leaks… + for ph in ["{reason}", "{plugins}", "{config_version}"] { + assert!(!md.contains(ph), "placeholder {ph} fully substituted"); + } + // …and the catalog is withheld until a language is chosen. + assert!( + !md.contains("## Principles & metrics") && !md.contains("### ADP"), + "catalog omitted: {md}" + ); +} diff --git a/crates/code-ranker-cli/src/cli.rs b/crates/code-ranker-cli/src/cli.rs index d5ac527b..09a48cb4 100644 --- a/crates/code-ranker-cli/src/cli.rs +++ b/crates/code-ranker-cli/src/cli.rs @@ -255,12 +255,26 @@ pub(crate) enum Command { doc_id: Option<String>, }, - /// Assemble the embedded doc corpus into a directory for publishing (e.g. - /// GitHub Pages): `base/<ID>.md` copied as-is, each language manifest emitted as - /// its assembled Markdown, full language docs copied verbatim. No analysis. - Docs { - /// Output directory for the composed corpus. - #[arg(long = "out", value_name = "DIR", default_value = "site")] - out: PathBuf, + /// Print the offline AI-agent playbook to stdout — no analysis, always exits 0. + /// Output adapts to whether a language plugin can be resolved (explicit + /// `--plugin` > config `plugin` > auto-detect from `[input]` markers): when one + /// is resolved, it prints the full playbook + principle/metric catalog; when + /// none can be resolved (no marker, or ambiguous markers), it prints a brief + /// product intro plus how to select a plugin, and omits the catalog. + Ai { + /// Directory whose markers decide the output mode (default: current dir). + /// No analysis is run — only plugin resolution. + #[arg(default_value = ".")] + input: PathBuf, + + /// Plugin: rust | python | javascript | auto. Resolves the mode explicitly + /// (skips auto-detection). + #[arg(long)] + plugin: Option<String>, + + /// Config file path, or inline `KEY=VALUE` override (repeatable) — consulted + /// only for its `plugin` key when resolving the mode. + #[arg(long, value_name = "PATH | KEY=VALUE")] + config: Vec<String>, }, } diff --git a/crates/code-ranker-cli/src/main.rs b/crates/code-ranker-cli/src/main.rs index 9b908a30..1d6d663a 100644 --- a/crates/code-ranker-cli/src/main.rs +++ b/crates/code-ranker-cli/src/main.rs @@ -7,6 +7,7 @@ // while binding no name, so nothing can accidentally reach into it. extern crate code_ranker_plugins as _; +mod ai; mod analyze; mod check; mod cli; @@ -115,9 +116,14 @@ fn main() -> Result<()> { }, ), }, - Command::Docs { out } => templates::build_corpus(&out).map(|n| { - logger::info(&format!("docs: wrote {n} files to {}", out.display())); - }), + // `ai`: print the embedded AI playbook to stdout. No analysis — only plugin + // resolution, which picks the full playbook (resolved) vs. a brief intro + + // plugin-setup guidance (unresolved). See `ai.rs`. + Command::Ai { + input, + plugin, + config, + } => ai::run(&input, plugin.as_deref(), &config), }; match &res { Ok(_) => { diff --git a/crates/code-ranker-cli/src/templates.rs b/crates/code-ranker-cli/src/templates.rs index efe1d7e3..45d64a83 100644 --- a/crates/code-ranker-cli/src/templates.rs +++ b/crates/code-ranker-cli/src/templates.rs @@ -86,6 +86,14 @@ fn is_manifest(md: &str) -> bool { /// doc has none), so the AI overview always lists the current catalog. const TLDR_INDEX_MARKER: &str = "<!-- doc:tldr-index -->"; +/// Markers bracketing the `base/AI.md` *Select a language* section — the plugin-setup +/// template (with `{reason}` / `{plugins}` / `{config_version}` placeholders filled by +/// the `ai` command, see `ai::fill_select`). The `ai` command shows it (the intro, +/// [`ai_doc_intro`]) only when no plugin resolves; every other served doc strips the +/// whole section ([`expand_tldr_index`]) so its template never reaches the terminal. +const AI_SELECT_START: &str = "<!-- ai:select-start -->"; +const AI_SELECT_END: &str = "<!-- ai:select-end -->"; + /// 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. @@ -151,12 +159,27 @@ fn tldr_index() -> String { } /// Replace a `<!-- doc:tldr-index -->` marker with the generated catalog; a no-op -/// for docs that don't carry it. +/// 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 { + let md = strip_select_section(md); if md.contains(TLDR_INDEX_MARKER) { md.replace(TLDR_INDEX_MARKER, &tldr_index()) } else { - md.to_string() + md + } +} + +/// Remove the `<!-- ai:select-start -->`…`<!-- ai:select-end -->` block (inclusive), +/// rejoining the surrounding text with a blank line. A no-op for docs without it. +fn strip_select_section(md: &str) -> String { + match (md.find(AI_SELECT_START), md.find(AI_SELECT_END)) { + (Some(start), Some(end)) if start < end => { + let after = &md[end + AI_SELECT_END.len()..]; + format!("{}\n\n{}", md[..start].trim_end(), after.trim_start()) + } + _ => md.to_string(), } } @@ -168,6 +191,30 @@ pub(crate) fn resolve_doc( Ok(expand_tldr_index(&resolve_doc_raw(snap, templates, id)?)) } +/// The offline AI-agent overview (`base/AI.md`) with its catalog index expanded — +/// identical to `report --doc AI`, but served straight from the embedded corpus +/// with **no snapshot, no project analysis, and no plugin detection**. Backs the +/// `ai` subcommand, so the playbook prints in any directory regardless of language +/// markers. +pub(crate) fn ai_doc() -> Result<String> { + let md = corpus_doc("base/AI.md").context("base/AI.md is not embedded in this build")?; + Ok(expand_tldr_index(md)) +} + +/// The brief intro from `base/AI.md` for the `ai` command's unresolved mode: +/// everything up to and including the *Select a language* section (its placeholders +/// still raw — `ai::fill_select` fills them). That is the product description, the +/// command list, and the plugin-setup template; the analysis playbook + catalog after +/// it stay withheld until a language is chosen. +pub(crate) fn ai_doc_intro() -> Result<String> { + let md = corpus_doc("base/AI.md").context("base/AI.md is not embedded in this build")?; + let head = md.split(AI_SELECT_END).next().unwrap_or(md); + Ok(format!( + "{}\n", + head.replace(&format!("{AI_SELECT_START}\n"), "").trim() + )) +} + /// Resolve the Markdown for doc `id` against the active snapshot. In order: /// 1. a `[templates.languages.<lang>.<ID>]` override → that file verbatim (the /// user supplies the final doc, "as if it were `languages/<lang>/<ID>.md`"); @@ -218,43 +265,6 @@ fn resolve_doc_raw(snap: &Snapshot, templates: &TemplatesConfig, id: &str) -> Re .with_context(|| format!("doc {rel} is not embedded in this build")) } -/// Write the published doc corpus into `out_dir` for GitHub Pages: every -/// `base/<ID>.md` copied as-is (the fallback corpus), every language **manifest** -/// (a `<lang>/<ID>.md` carrying `<!-- doc:base … -->` includes) emitted as its -/// assembled doc, and any full `<lang>/<ID>.md` copied through verbatim. Returns the -/// file count. -pub(crate) fn build_corpus(out_dir: &std::path::Path) -> Result<usize> { - let mut written = 0usize; - for (rel, contents) in CORPUS { - let (lang, file) = match rel.split_once('/') { - Some(p) => p, - None => continue, - }; - let stem = file.strip_suffix(".md").unwrap_or(file); - - let body: String = if lang != "base" && is_manifest(contents) { - // Assemble: the published `<lang>/<ID>.md` is the manifest over base. - let base = corpus_doc(&format!("base/{stem}.md")) - .with_context(|| format!("manifest {rel} has no base/{stem}.md"))?; - crate::compose::compose(contents, base, lang_display(lang))? - } else { - // `base/*` and full language docs are published verbatim (the AI index's - // `<!-- doc:tldr-index -->` marker is expanded so Pages ships the catalog, - // not the raw marker). - expand_tldr_index(contents) - }; - - let dest = out_dir.join(rel); - if let Some(parent) = dest.parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("creating {}", parent.display()))?; - } - std::fs::write(&dest, &body).with_context(|| format!("writing {}", dest.display()))?; - written += 1; - } - Ok(written) -} - /// Display name for a corpus language folder, used as the H1 `(in <Lang>)` suffix. fn lang_display(lang: &str) -> &str { match lang { diff --git a/crates/code-ranker-cli/src/templates_test.rs b/crates/code-ranker-cli/src/templates_test.rs index c1940b06..071ac3de 100644 --- a/crates/code-ranker-cli/src/templates_test.rs +++ b/crates/code-ranker-cli/src/templates_test.rs @@ -183,23 +183,6 @@ fn resolve_doc_unknown_id_errors() { assert!(msg.contains("SRP"), "known principles listed: {msg}"); } -#[test] -fn build_corpus_writes_every_doc_including_assembled() { - let dir = tempfile::tempdir().unwrap(); - let n = build_corpus(dir.path()).unwrap(); - assert!(n > 0, "wrote at least one doc"); - - // Base docs are copied verbatim. - assert!(dir.path().join("base/HK.md").exists()); - - // A rust manifest is published assembled, with its includes expanded. - let assembled = std::fs::read_to_string(dir.path().join("rust/ADP.md")).unwrap(); - assert!( - !assembled.contains("<!-- doc:base"), - "manifest includes expanded on publish" - ); -} - #[test] fn lang_display_maps_known_folders_and_passes_through() { assert_eq!(lang_display("rust"), "Rust"); @@ -272,6 +255,82 @@ fn resolve_doc_ai_index_expands_tldr_marker() { ); } +#[test] +fn ai_doc_matches_resolve_doc_and_needs_no_snapshot() { + // `ai_doc()` backs the project-free `ai` subcommand: it must produce exactly + // what `report --doc AI` does, but without a snapshot or plugin. + let doc = ai_doc().unwrap(); + let via_resolve = resolve_doc( + &snap(vec![], BTreeMap::new()), + &TemplatesConfig::default(), + "AI", + ) + .unwrap(); + assert_eq!(doc, via_resolve, "ai_doc == report --doc AI output"); + assert!( + doc.contains("code-ranker — AI agent skill"), + "overview head" + ); + assert!(!doc.contains("doc:tldr-index"), "catalog marker expanded"); + assert!( + !doc.contains("ai:select"), + "select-section markers stripped" + ); + assert!( + doc.contains("## Commands") && doc.contains("**`help`**"), + "the playbook lists the main commands incl. help" + ); + assert!( + doc.contains("## Principles & metrics") && doc.contains("### ADP"), + "the resolved-mode doc carries the full catalog" + ); + // The Select-a-language section is stripped in the resolved doc — plugin setup is + // only shown by the `ai` command's unresolved branch (see `ai::fill_select`). Its + // placeholders must never leak into a served doc. + assert!( + !doc.contains("## Select a language"), + "resolved playbook never mentions how to set the plugin" + ); + for ph in ["{reason}", "{plugins}", "{config_version}"] { + assert!( + !doc.contains(ph), + "no template placeholder {ph} in a served doc" + ); + } +} + +#[test] +fn ai_doc_intro_keeps_description_and_commands_but_not_the_playbook() { + let intro = ai_doc_intro().unwrap(); + assert!( + intro.contains("code-ranker — AI agent skill") && intro.contains("**TL;DR**"), + "intro keeps the title + product description" + ); + assert!( + intro.contains("## Commands") + && intro.contains("**`check") + && intro.contains("**`report") + && intro.contains("**`ai`**") + && intro.contains("**`help`**"), + "intro lists the main commands: {intro}" + ); + // Carries the Select-a-language template (placeholders still raw — `ai` fills them). + assert!( + intro.contains("## Select a language") && intro.contains("{plugins}"), + "intro includes the plugin-setup template from the doc: {intro}" + ); + assert!( + !intro.contains("ai:select"), + "bracketing markers not included" + ); + // Stops before the analysis playbook + catalog (those wait for a plugin). + assert!( + !intro.contains("## The two that matter most") + && !intro.contains("## Principles & metrics"), + "intro stops before the analysis playbook: {intro}" + ); +} + #[test] fn resolve_doc_resolves_base_doc_by_filename_stem() { // Docs that are neither a principle nor a node attribute resolve by their base diff --git a/crates/code-ranker-cli/tests/e2e.rs b/crates/code-ranker-cli/tests/e2e.rs index 895bbceb..abf27693 100644 --- a/crates/code-ranker-cli/tests/e2e.rs +++ b/crates/code-ranker-cli/tests/e2e.rs @@ -69,13 +69,16 @@ fn sample_dir(lang: &str) -> PathBuf { /// Run the binary on the language's `sample/` project with its own config and /// return the parsed JSON report. fn run_report(lang: &str) -> Value { - let root = repo_root(); let sample = sample_dir(lang); let out_dir = tempfile::tempdir().expect("create temp output dir"); let out_json = out_dir.path().join("fresh.json"); + // Run from the temp dir: the report also emits the default html (the built-in + // `[output.html]` path is always set, so it is written even when only the json + // path is overridden), which would otherwise litter the repo's own `.code-ranker/`. + // cwd doesn't affect analysis — sample and config are absolute paths. let status = Command::new(env!("CARGO_BIN_EXE_code-ranker")) - .current_dir(&root) + .current_dir(out_dir.path()) .env("CARGO_NET_OFFLINE", "true") // Rust sample resolves crates from cache .arg("report") .arg(&sample) @@ -238,10 +241,15 @@ fn assert_sample_matches(lang: &str) { /// stderr (instead of comparing a golden file). Used for the recommendation /// formats (`scorecard` / `prompt`), which stream to stdout. fn run_report_capture(lang: &str, extra: &[&str]) -> (bool, String, String) { - let root = repo_root(); let sample = sample_dir(lang); + // Run from a throwaway cwd. A `report` with no explicit json/html path falls back + // to the default `.code-ranker/{ts}-…` pair (the recommendation formats — scorecard + // / prompt — never suppress it), which, run from the repo root, would litter the + // repo's own `.code-ranker/`. cwd doesn't affect analysis: `sample` and its config + // are absolute, and the plugin reads the sample dir, not cwd. + let cwd = tempfile::tempdir().expect("create temp cwd"); let out = Command::new(env!("CARGO_BIN_EXE_code-ranker")) - .current_dir(&root) + .current_dir(cwd.path()) .env("CARGO_NET_OFFLINE", "true") .arg("report") .arg(&sample) @@ -436,12 +444,12 @@ fn rust_sample_check_suggest_config() { /// (no new violations). #[test] fn rust_sample_check_baseline_verdict_neutral() { - let root = repo_root(); let sample = sample_dir("rust"); + let cwd = tempfile::tempdir().expect("temp cwd"); // keep default html out of the repo let tmp = std::env::temp_dir().join("cs-e2e-baseline-rust.json"); // Capture a baseline snapshot. let report = Command::new(env!("CARGO_BIN_EXE_code-ranker")) - .current_dir(&root) + .current_dir(cwd.path()) .env("CARGO_NET_OFFLINE", "true") .arg("report") .arg(&sample) @@ -471,11 +479,11 @@ fn rust_sample_check_baseline_verdict_neutral() { /// trailing `Baseline verdict:` line (the json test above covers the wrapper). #[test] fn rust_sample_check_baseline_verdict_human() { - let root = repo_root(); let sample = sample_dir("rust"); + let cwd = tempfile::tempdir().expect("temp cwd"); // keep default html out of the repo let tmp = std::env::temp_dir().join("cs-e2e-baseline-rust-human.json"); let report = Command::new(env!("CARGO_BIN_EXE_code-ranker")) - .current_dir(&root) + .current_dir(cwd.path()) .env("CARGO_NET_OFFLINE", "true") .arg("report") .arg(&sample) @@ -567,26 +575,78 @@ fn rust_sample_report_rejects_prompt_with_doc() { ); } -/// `docs --out <dir>` assembles the embedded corpus to disk: `base/*` verbatim and -/// each language manifest as its composed Markdown. No analysis runs. +/// `ai` with **no resolvable plugin** (an empty directory — no markers) exits `0` +/// and prints the brief intro plus how to select a plugin, **withholding** the +/// principle/metric catalog until a language is chosen. #[test] -fn docs_subcommand_writes_the_corpus() { - let root = repo_root(); - let out = std::env::temp_dir().join("cs-e2e-docs-corpus"); - let _ = std::fs::remove_dir_all(&out); +fn ai_unresolved_omits_catalog_and_shows_plugin_setup() { + let dir = std::env::temp_dir().join("cr-e2e-ai-unresolved"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); let res = Command::new(env!("CARGO_BIN_EXE_code-ranker")) - .current_dir(&root) - .arg("docs") - .arg("--out") - .arg(&out) + .current_dir(&dir) + .arg("ai") .output() - .expect("spawn docs"); - assert!(res.status.success(), "docs run failed"); - assert!(out.join("base/ADP.md").exists(), "base doc copied verbatim"); - let assembled = std::fs::read_to_string(out.join("rust/ADP.md")).unwrap(); + .expect("spawn ai"); + assert!( + res.status.success(), + "ai must exit 0 even with no plugin: {}", + String::from_utf8_lossy(&res.stderr) + ); + let stdout = String::from_utf8_lossy(&res.stdout); + assert!( + stdout.contains("code-ranker — AI agent skill"), + "brief product intro present: {stdout}" + ); + assert!( + stdout.contains("## Commands") + && stdout.contains("**`help`**") + && stdout.contains("**`report"), + "lists the main commands (check/report/ai/help)" + ); + assert!( + stdout.contains("## Select a language") && stdout.contains("--plugin"), + "tells the user how to select a plugin" + ); + // The doc template's placeholders are filled (live plugin list + version), not leaked. assert!( - !assembled.contains("<!-- doc:base"), - "manifest includes expanded on publish: {assembled}" + stdout.contains("rust") + && !stdout.contains("{plugins}") + && !stdout.contains("{config_version}"), + "Select-a-language placeholders are substituted: {stdout}" + ); + assert!( + !stdout.contains("## Principles & metrics") && !stdout.contains("### ADP"), + "catalog withheld until a plugin is resolved: {stdout}" + ); +} + +/// `ai` run inside a project whose plugin **auto-detects** (the Rust sample) prints +/// the full playbook + catalog and never mentions plugin setup. +#[test] +fn ai_resolved_prints_full_catalog_without_setup() { + let res = Command::new(env!("CARGO_BIN_EXE_code-ranker")) + .current_dir(sample_dir("rust")) + .arg("ai") + .output() + .expect("spawn ai"); + assert!( + res.status.success(), + "ai failed: {}", + String::from_utf8_lossy(&res.stderr) + ); + let stdout = String::from_utf8_lossy(&res.stdout); + assert!( + stdout.contains("### ADP — Acyclic Dependencies Principle"), + "full catalog present when the plugin resolves: {stdout}" + ); + assert!( + !stdout.contains("## Select a language"), + "resolved mode never shows plugin setup" + ); + assert!( + !stdout.contains("doc:tldr-index"), + "catalog marker expanded, not literal" ); } @@ -626,16 +686,17 @@ fn report_default_writes_json_and_html() { /// `…-diff.html` (the rename branch of the html artifact path). #[test] fn report_baseline_html_is_named_diff() { - let root = repo_root(); let sample = sample_dir("rust"); let dir = std::env::temp_dir().join("cs-e2e-report-diff"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let base = dir.join("base.json"); let html = dir.join("viewer.html"); - // Capture a baseline snapshot. + // Run from `dir`: each command overrides only one of json/html, and the other is + // still emitted by default (the built-in `[output.*]` paths are always set) — keep + // that default artifact in the temp dir, not the repo's `.code-ranker/`. let cap = Command::new(env!("CARGO_BIN_EXE_code-ranker")) - .current_dir(&root) + .current_dir(&dir) .env("CARGO_NET_OFFLINE", "true") .arg("report") .arg(&sample) @@ -647,7 +708,7 @@ fn report_baseline_html_is_named_diff() { assert!(cap.status.success(), "baseline capture failed"); // Render the HTML viewer against that baseline. let res = Command::new(env!("CARGO_BIN_EXE_code-ranker")) - .current_dir(&root) + .current_dir(&dir) .env("CARGO_NET_OFFLINE", "true") .arg("report") .arg(&sample) diff --git a/docs/ai-skill.md b/docs/ai-skill.md index 6439f10d..b5356c9e 100644 --- a/docs/ai-skill.md +++ b/docs/ai-skill.md @@ -25,10 +25,19 @@ platform notes): [installation.md](installation.md). - **`report`** — produces artifacts: a JSON snapshot, an HTML viewer, and the advisory **`scorecard`** (console triage) / **`prompt`** (LLM prompt). Always exits `0`. +- **`ai`** — prints this playbook to the terminal (no analysis; always exits `0`). + Run `code-ranker ai` to bootstrap: with a language plugin resolved it prints the + full playbook + principle/metric catalog; with none (no/ambiguous markers) it + prints a brief intro and how to select one. `[input]` is polymorphic: a directory is analyzed; a `.json` snapshot is read back with no re-analysis. Keep old `.code-ranker/` snapshots — they are baselines. +`check` / `report` analyze one language, auto-detected from project markers. If a +directory has markers for several (e.g. Rust + Markdown), they stop with *"ambiguous +project … pass --plugin to choose"*: name the language with `--plugin <name>`, or set +`plugin = "<name>"` in a `code-ranker.toml` at the project root. (`ai` never needs this.) + ## The two metrics that matter Focus on these; treat everything else as secondary. diff --git a/docs/code-ranker-cli/CLI.md b/docs/code-ranker-cli/CLI.md index 76f0c0e7..af22b726 100644 --- a/docs/code-ranker-cli/CLI.md +++ b/docs/code-ranker-cli/CLI.md @@ -24,13 +24,14 @@ exact command per entry (triage, CI gates, focused checks, baselines, AI prompts |---|---| | [`check`](#check) | A **verdict**: evaluates thresholds, cycle rules, and (with `--baseline`) regressions, prints diagnostics, and **exits non-zero** on violation. Writes no files. | | [`report`](#report) | **Artifacts**: an HTML viewer and/or a JSON snapshot. With `--baseline`, the HTML becomes a diff with a verdict. Can also emit a console **scorecard** triage and an AI **prompt** (see [Recommendations](#recommendations-scorecard--prompt)). Always exits `0`. | +| [`ai`](#ai) | The offline **AI-agent playbook** to stdout. Never analyzes, always exits `0`. With a resolvable language plugin it prints the full playbook + principle/metric catalog; with none (no marker, or ambiguous markers) it prints a brief intro and how to select one. | There are two analysis commands, split by *what they emit*: `check` produces an exit code (a CI gate), `report` produces files (a snapshot and a viewer). Both take the same -input and share the same vocabulary below. (A third, maintenance-only `docs` -subcommand publishes the principle/metric doc corpus — composing each language -manifest over its base — for GitHub Pages; see -[templates.md](../templates.md).) +input and share the same vocabulary below. A third command, `ai`, reads no project and +just prints the embedded agent playbook. (The principle/metric doc corpus is not +published — it is embedded in the binary and printed on demand with `report --doc <ID>` +or, for the overview, `code-ranker ai`; see [templates.md](../templates.md).) ## Global options @@ -591,6 +592,32 @@ code-ranker report . --doc HK # the full HK principle text, to stdo code-ranker report . --doc AI # AI playbook + the principle/metric catalog ``` +For the AI overview, prefer the [`ai`](#ai) command — instead of erroring on an +ambiguous project it prints the playbook, adapting to whether a plugin resolves. + +## `ai` + +`code-ranker ai` prints the offline AI-agent playbook (from the embedded +`base/AI.md`) to stdout, then exits `0`. It **never analyzes** — it only resolves +which language plugin applies (explicit `--plugin` > the `plugin` config key > +auto-detect from `[input]`'s markers, default `.`) to choose what to print: + +- **plugin resolved** → the full playbook **plus** the principle/metric catalog (the + TL;DR index expanded) — the project-free equivalent of `report --doc AI`. It does + not mention plugin setup; the language is already known. +- **no plugin resolvable** (no project marker, or markers for more than one language) + → a brief product intro **plus** a *Select a language* section explaining how to + choose one (`--plugin <name>`, or the `plugin` key in `code-ranker.toml`) and + listing the built-ins. The catalog is **withheld** until a language is chosen. + +So `ai` always succeeds — even where `report` / `check` would stop with *"ambiguous +project … pass --plugin to choose"* — and tells the user how to proceed. + +```sh +code-ranker ai # auto-detect: full playbook, or how to pick a plugin +code-ranker ai --plugin rust # force a language → the full playbook + catalog +``` + ## `--baseline` (comparison) Both commands accept `--baseline <snapshot>` (a `.json` snapshot or a prior `.html` diff --git a/docs/code-ranker-cli/DESIGN.md b/docs/code-ranker-cli/DESIGN.md index e479982c..bdada965 100644 --- a/docs/code-ranker-cli/DESIGN.md +++ b/docs/code-ranker-cli/DESIGN.md @@ -29,16 +29,20 @@ viewer assets see [`code-ranker-viewer/DESIGN.md`](../code-ranker-viewer/DESIGN. - [x] `p1` - **ID**: `cpt-code-ranker-component-cli` The single user-facing binary `code-ranker`. There is no default command — -a bare invocation prints help. `main()` owns two subcommands — `check` and -`report` — both taking a single polymorphic positional `[input]` (a directory +a bare invocation prints help. `main()` owns two analysis subcommands — `check` +and `report` — both taking a single polymorphic positional `[input]` (a directory to **analyze**, or a `.json`/`.html` snapshot to **read**, via -`analyze_input` → `is_snapshot_input`): +`analyze_input` → `is_snapshot_input`); a third `ai` subcommand (`ai.rs`) runs **no +analysis** — it resolves the language plugin (`plugin::resolve_plugin`) only to pick +the output and prints the embedded `base/AI.md` playbook to stdout (full playbook + +catalog when a plugin resolves; a brief intro + how to select one when none does): The binary is decomposed by concern — `main()` only parses and dispatches: `cli.rs` (the clap argument model), `analyze.rs` (input dispatch, the snapshot path, and snapshot loading), `pipeline.rs` (the directory-analysis pipeline + `LevelGraph` assembly, owning the `Analyzed` result), `check.rs` (`run_check`), -`report.rs` (`run_report`), `recommend.rs` (prompt/scorecard), and the `config/` +`report.rs` (`run_report`), `recommend.rs` (prompt/scorecard), `ai.rs` (the `ai` +playbook command), and the `config/` module (`model` / `load` / `ignore` / `rules` / `violations`, re-exported through its `mod.rs` facade). `pipeline.rs` concentrates the high fan-out orchestration behind a single caller (`analyze_input`), keeping every file's Henry-Kafura HK @@ -274,8 +278,9 @@ This section notes the implementation binding. The full CLI surface is documented in [CLI.md](CLI.md). The two analysis commands are `check` (verdict + exit code, no files) and `report` (artifacts); both take a -polymorphic `[input]` and accept `--baseline <snapshot>`. A third, maintenance-only -`docs` subcommand assembles the doc corpus for publishing (no analysis). +polymorphic `[input]` and accept `--baseline <snapshot>`. The doc corpus is embedded +in the binary and printed on demand via `report --doc <ID>` — there is no separate +publishing subcommand. ### Snapshots — `code-ranker report --output.json` diff --git a/docs/code-ranker-cli/PRD.md b/docs/code-ranker-cli/PRD.md index 1a8a9eab..e46c2d3b 100644 --- a/docs/code-ranker-cli/PRD.md +++ b/docs/code-ranker-cli/PRD.md @@ -29,13 +29,14 @@ report requirements see [`code-ranker-viewer/PRD.md`](../code-ranker-viewer/PRD. All user-facing operations MUST be accessible through a single binary `code-ranker`. Running it with no command prints help — every action goes through an explicit subcommand; there is no default command. There are -exactly **two** subcommands, split by *what they emit* — `check` produces +**two** analysis subcommands, split by *what they emit* — `check` produces an exit code (a CI gate), `report` produces files (a snapshot and a -viewer): +viewer) — plus a small project-free `ai` command (below): ``` code-ranker check [input] [--plugin <name|auto>] [--baseline <snapshot>] [options] code-ranker report [input] [--plugin <name|auto>] [--baseline <snapshot>] [--output.<fmt>.path <path>] [options] +code-ranker ai [input] [--plugin <name|auto>] # offline agent playbook (no analysis) ``` The single positional `[input]` (default `.`) is **polymorphic**: a @@ -56,6 +57,15 @@ snapshot input. always exits `0`. Without `--baseline` the HTML is a single-snapshot viewer; with `--baseline <snapshot>` it becomes a baseline↔current diff view with a verdict, named `…-diff.html`. +- `ai` prints the offline AI-agent playbook (the embedded `base/AI.md`) to stdout + and always exits `0`. It runs **no analysis** — it only resolves which language + plugin applies (explicit `--plugin`, the `plugin` config key, or auto-detection + from `[input]`'s markers) to choose the output: with a plugin resolved it prints + the full playbook **plus** the principle/metric catalog (the project-free + equivalent of `report --doc AI`); with none resolvable (no marker, or markers for + several languages) it prints a brief product intro **plus** how to select a plugin + and **omits** the catalog. So it succeeds even where `report` / `check` would stop + on an ambiguous project, and guides the user to a working setup. `report` selects artifacts and their destinations through one flag family, `--output.<fmt>.path <path>` (`<fmt>` is `json`, `html`, `sarif`, `codequality`, diff --git a/docs/templates.md b/docs/templates.md index 7f4f71d6..d0a809f6 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -18,7 +18,7 @@ and overrides them, and the CLI surface that prints a prompt or a doc directly. | Family | What it is | Source today | Rendered by | |---|---|---|---| -| **Docs corpus** | per-principle / per-metric Markdown (`SRP.md`, `HK.md`, …) | `languages/<lang>/<ID>.md` | served by URL (`doc_url`); also embedded in the binary (§3) | +| **Docs corpus** | per-principle / per-metric Markdown (`SRP.md`, `HK.md`, …) | `languages/<lang>/<ID>.md` | embedded in the binary (§3) and addressed by `doc_url`; no longer served live (§5) | | **Prompt scaffolding** | the framing prose around a principle in an AI prompt (intro, task protocol, …) | `code-ranker-graph/metrics/prompt.md` → `PromptTemplate` | `compose_prompt` (CLI), `composePrompt` (viewer) | They are converging on **one composition engine** (§4) and one override mechanism @@ -126,8 +126,7 @@ One Rust composer implements `compose(manifest, base)`, used in three places so logic exists exactly once: 1. **Runtime** — embed fragments, compose on demand for CLI/viewer output (§3). -2. **`code-ranker docs build`** — write the composed corpus for publishing (§5). -3. **Prompt scaffolding** — the same section/`{key}` machinery renders the +2. **Prompt scaffolding** — the same section/`{key}` machinery renders the `PromptTemplate` (§8). It builds on the existing `{key}` interpolation in @@ -137,13 +136,14 @@ substitution primitive already in the tree. --- -## 5. Publishing to GitHub Pages ✅ +## 5. Publishing to GitHub Pages — removed -**Variant B — Pages-only.** The repo's `main` holds only the *source* (`base/` + -per-language manifests). A GitHub Action runs `code-ranker docs build` and deploys the composed -corpus to GitHub Pages; `doc_base` points at the Pages URL. No compiled files are -committed, so there is no drift gate to maintain — Pages is always rebuilt from -source. +Corpus publishing to GitHub Pages (and the `code-ranker docs` subcommand that +composed the corpus to disk) has been **removed**. The corpus is no longer served +over a URL; it lives only **embedded in the binary** (§3) and is reached through +`--doc <ID>` / inline prompt text. The Pages workflow still publishes the HTML +*report* (`report . → site/index.html`), but not the doc corpus, so a finding's +`doc_url` no longer resolves to a live page. --- @@ -245,8 +245,7 @@ project may substitute its own via `prompt_template_from()`), and is carried in snapshot as [`PromptTemplate`](../crates/code-ranker-plugin-api/src/principle.rs) so the CLI and the viewer render identical text from one source. Unlike the principle/metric corpus, `prompt.md` is **internal template prose**: it sits next to `builtin.toml` -(not under `languages/`), is not a `<lang>/<ID>` doc, and is not published by -`code-ranker docs`. +(not under `languages/`) and is not a `<lang>/<ID>` doc. | Field | Role | |---|---| @@ -288,9 +287,9 @@ and JS. | `[templates.languages.<lang>.<ID>]` per-file override | ✅ | | `report --prompt <ID>` / `--doc <ID>` | ✅ | | Manifest composer (`compose.rs`: `doc:base` + `from`/`to`) + `resolve_doc` wiring | ✅ | -| `code-ranker docs` build subcommand + Pages publishing (Variant B) | ✅ | +| `code-ranker docs` build subcommand + corpus Pages publishing (Variant B) | ✗ removed — corpus is binary-embedded only, not served over a URL | | `base/` + per-language manifest migration | ◐ all `rust/` docs migrated; `python`/`typescript` 🔜 | -| `doc_base` → Pages URL (activation; needs Pages live + goldens pass) | 🔜 | +| `doc_base` → Pages URL (activation) | ✗ dropped with corpus Pages publishing | | Pre-render prompt head into the snapshot (de-dup Rust↔JS) | ✗ deferred — net-negative (bloats the snapshot to remove ~20 stable JS lines) | See also: [customization/config-resolution.md](customization/config-resolution.md) · diff --git a/languages/base/AI.md b/languages/base/AI.md index f50146fc..6fed2127 100644 --- a/languages/base/AI.md +++ b/languages/base/AI.md @@ -1,20 +1,59 @@ # code-ranker — AI agent skill -**TL;DR**: A short playbook for an AI assistant driving `code-ranker`, plus a -catalog of every principle and metric it checks. Each catalog entry is a -one-paragraph summary; run `code-ranker report --doc <ID>` to print any entry in -full (offline, straight to the terminal). +**TL;DR**: `code-ranker` is a multi-language **structural analysis platform** an AI +assistant can drive. It builds a project's dependency graph, finds the structural +problems that make code hard to change — dependency **cycles** (ADP), heavy +**coupling** (Henry–Kafura), and complexity hotspots — ranks them worst-first, and +scores them against design principles (SOLID, DRY, KISS, …). It gates CI on your +thresholds, renders a self-contained HTML viewer of the graph, and emits +ready-to-use **AI fix-prompts**. One binary; a language plugin (Rust, Python, +JavaScript / TypeScript, Go, C / C++, C#, Markdown) is selected per project. -## Two commands +This is the short guide for driving it — the commands below operate the tool. -- **`check`** — a gate. Exits non-zero on a violation, writes no files. -- **`report`** — produces artifacts: a JSON snapshot, an HTML viewer, and the - advisory **`scorecard`** (console triage) / **`prompt`** (LLM fix-prompt). Always - exits `0`. +## Commands -`[input]` is polymorphic: a directory is analyzed; a `.json` snapshot is read back -with no re-analysis. Keep old `.code-ranker/` snapshots — they are baselines for a -before/after diff (`--baseline <snapshot>`). +- **`check [input]`** — the **gate**. Evaluates cycle rules and metric thresholds + (with `--baseline`, only regressions), prints diagnostics, and **exits non-zero** + on a violation. Writes no files — the CI entry point. +- **`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. +- **`ai`** — print this playbook. With a language plugin resolved it appends the + full principle/metric catalog; with none it explains how to select one. No + analysis; always exits `0`. +- **`help`** — usage for the binary or any command (`code-ranker --help`, + `code-ranker <command> --help`, or `-h <command>`). Lists every flag. + +`[input]` (default `.`) is polymorphic: a directory is analyzed; a `.json` / `.html` +snapshot is read back with no re-analysis. Keep old `.code-ranker/` snapshots — they +are baselines for a before/after diff (`--baseline <snapshot>`). + +<!-- ai:select-start --> +## Select a language + +`code-ranker` analyzes **one** language per run, selected by a plugin — and none +could be resolved here: + +> {reason} + +Pick one of: **{plugins}**. Either name it per run (applies to `check` / `report` +too): + +```sh +code-ranker check . --plugin <name> +``` + +…or set it once in a `code-ranker.toml` at the project root, so every command picks +it up: + +```toml +version = "{config_version}" +plugin = "<name>" +``` + +Then re-run `code-ranker ai` for the full playbook and the principle/metric catalog. +<!-- ai:select-end --> ## The two that matter most @@ -40,4 +79,7 @@ principle, by that design principle. ## Principles & metrics +Each entry summarizes one principle or metric; run `code-ranker report --doc <ID>` +to print its full doc (offline, straight to the terminal). + <!-- doc:tldr-index --> From 19526fb653c8a743f07048e0b3ffd415a24eb584 Mon Sep 17 00:00:00 2001 From: Roman Fedorov <Roman.Fedorov@constructor.tech> Date: Tue, 23 Jun 2026 19:34:29 +0300 Subject: [PATCH 07/40] test(cli): close diff-coverage gaps in ai/report/templates The diff-coverage gate flagged 7 added lines with no test: - report.rs `--output.prompt --focus <metric>|<principle>`: two e2e tests for the metric-lens and principle arms of the prompt builder. - templates.rs: extract `catalog_entry` (no-summary arm unreachable via the real corpus) and a shared `with_trailing_newline` helper (DRY with the ai and report `--doc` stdout paths); unit-test both arms. diff-coverage vs origin/main: every added/changed Rust line covered. --- crates/code-ranker-cli/src/ai.rs | 5 +-- crates/code-ranker-cli/src/report.rs | 5 +-- crates/code-ranker-cli/src/templates.rs | 29 ++++++++++++--- crates/code-ranker-cli/src/templates_test.rs | 35 ++++++++++++++++++ crates/code-ranker-cli/tests/e2e.rs | 37 ++++++++++++++++++++ 5 files changed, 98 insertions(+), 13 deletions(-) diff --git a/crates/code-ranker-cli/src/ai.rs b/crates/code-ranker-cli/src/ai.rs index ea93f0c6..363f1214 100644 --- a/crates/code-ranker-cli/src/ai.rs +++ b/crates/code-ranker-cli/src/ai.rs @@ -26,10 +26,7 @@ pub(crate) fn run(input: &Path, plugin_arg: Option<&str>, config_entries: &[Stri Err(reason) => fill_select(&templates::ai_doc_intro()?, &reason.to_string()), }; - print!("{md}"); - if !md.ends_with('\n') { - println!(); - } + print!("{}", templates::with_trailing_newline(md)); Ok(()) } diff --git a/crates/code-ranker-cli/src/report.rs b/crates/code-ranker-cli/src/report.rs index 544b9095..5a2c0cd9 100644 --- a/crates/code-ranker-cli/src/report.rs +++ b/crates/code-ranker-cli/src/report.rs @@ -238,10 +238,7 @@ fn run_direct(args: &AnalyzeArgs, reco: &ReportReco) -> Result<()> { // `--doc <ID>`: the resolved corpus Markdown (with any `[templates.…]` override). if let Some(id) = &reco.doc_id { let md = crate::templates::resolve_doc(snap, &a.templates, id)?; - print!("{md}"); - if !md.ends_with('\n') { - println!(); - } + print!("{}", crate::templates::with_trailing_newline(md)); return Ok(()); } diff --git a/crates/code-ranker-cli/src/templates.rs b/crates/code-ranker-cli/src/templates.rs index 45d64a83..467d853a 100644 --- a/crates/code-ranker-cli/src/templates.rs +++ b/crates/code-ranker-cli/src/templates.rs @@ -126,6 +126,29 @@ fn doc_summary(md: &str) -> Option<String> { 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 `ai` and `report --doc` stdout paths so the rule lives in one +/// (unit-testable) place rather than being re-implemented at each call site. +pub(crate) fn with_trailing_newline(mut md: String) -> String { + if !md.ends_with('\n') { + md.push('\n'); + } + md +} + +/// One catalog entry: a `### <title>` heading + a `--doc <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 report --doc {stem}`"); + match summary { + Some(s) => format!("{head}\n\n{s}"), + None => head, + } +} + /// 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. @@ -142,11 +165,7 @@ fn tldr_index() -> String { .find_map(|l| l.strip_prefix("# ")) .unwrap_or(stem) .trim(); - let head = format!("### {title}\n\nFull doc: `code-ranker report --doc {stem}`"); - let entry = match doc_summary(contents) { - Some(s) => format!("{head}\n\n{s}"), - None => head, - }; + let entry = catalog_entry(title, stem, doc_summary(contents).as_deref()); Some((stem.to_ascii_lowercase(), entry)) }) .collect(); diff --git a/crates/code-ranker-cli/src/templates_test.rs b/crates/code-ranker-cli/src/templates_test.rs index 071ac3de..38fa13a9 100644 --- a/crates/code-ranker-cli/src/templates_test.rs +++ b/crates/code-ranker-cli/src/templates_test.rs @@ -360,3 +360,38 @@ fn doc_summary_prefers_tldr_then_first_paragraph() { Some("First prose paragraph. still it.") ); } + +#[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 report --doc HK`"), + "carries the --doc pointer: {with}" + ); + assert!( + with.ends_with("A coupling metric."), + "summary appended: {with}" + ); + + // 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 report --doc EC`" + ); +} + +#[test] +fn with_trailing_newline_appends_only_when_missing() { + assert_eq!(with_trailing_newline("x".to_string()), "x\n"); + assert_eq!( + with_trailing_newline("x\n".to_string()), + "x\n", + "already terminated → unchanged, no double newline" + ); + assert_eq!(with_trailing_newline(String::new()), "\n"); +} diff --git a/crates/code-ranker-cli/tests/e2e.rs b/crates/code-ranker-cli/tests/e2e.rs index abf27693..6a219772 100644 --- a/crates/code-ranker-cli/tests/e2e.rs +++ b/crates/code-ranker-cli/tests/e2e.rs @@ -804,6 +804,43 @@ fn rust_sample_prompt_flag_targets_named_principle() { ); } +/// `--output.prompt --focus <metric>` frames the auto-targeted prompt through the +/// metric lens (a synthesized metric-principle), titled by the metric rather than a +/// SOLID principle — the `Focus::Metric` arm of the `--output.prompt` builder. +#[test] +fn rust_sample_output_prompt_focus_metric_uses_metric_lens() { + let (ok, stdout, stderr) = run_report_capture( + "rust", + &["--output.prompt.path=stdout", "--focus", "HK", "--top", "1"], + ); + assert!(ok, "focus-metric prompt failed: {stderr}"); + assert!( + stdout.starts_with("# HK — Henry–Kafura"), + "metric-lens prompt titled by the metric: {stdout}" + ); +} + +/// `--output.prompt --focus <principle>` targets that named principle instead of +/// the auto-worst — the `Focus::Principle` arm of the `--output.prompt` builder. +#[test] +fn rust_sample_output_prompt_focus_principle_targets_it() { + let (ok, stdout, stderr) = run_report_capture( + "rust", + &[ + "--output.prompt.path=stdout", + "--focus", + "SRP", + "--top", + "1", + ], + ); + assert!(ok, "focus-principle prompt failed: {stderr}"); + assert!( + stdout.starts_with("# SRP — Single Responsibility Principle"), + "principle-focused prompt, not the auto-worst: {stdout}" + ); +} + /// `report --doc <ID>` prints the embedded corpus Markdown for a principle/metric /// directly. `HK` is a metric (its doc lives in `base/`, reached via the metric's /// remediation URL), exercising the metric-doc resolution path. From 90f2939fa3d2cb32bf1d14dc86fc046fd1eb5532 Mon Sep 17 00:00:00 2001 From: Roman Fedorov <Roman.Fedorov@constructor.tech> Date: Tue, 23 Jun 2026 19:34:30 +0300 Subject: [PATCH 08/40] docs(rust): document visibility-narrowing as an ADP cycle remedy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a cycle's only back-edges to the module root are `pub(in crate::…::<ancestor>)` visibility paths (not `use` statements), the smallest fix is to tighten the visibility (`pub(super)`/`pub(crate)`): Code Ranker models the visibility path as a dependency edge, so narrowing it removes the edge with no restructure. Guarded on every consumer already living in the narrower scope (else extract/invert) so cheaper models don't narrow blindly and silence the metric. Surfaced by the prompt-eval reference runs. --- languages/rust/ADP.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/languages/rust/ADP.md b/languages/rust/ADP.md index 03971155..e1c3bc72 100644 --- a/languages/rust/ADP.md +++ b/languages/rust/ADP.md @@ -48,6 +48,41 @@ pub struct B { pub a: Option<A> } This compiles. The compiler does not flag it. Code Ranker does. +### Visibility paths count as dependency edges + +Code Ranker builds the module graph from **both** `use` +imports **and visibility paths**. A `pub(in crate::a::b)` +item is reachable from anywhere under `crate::a::b`, so it +records an edge from the item's module **up to** that +ancestor — even when nothing there calls it. That is a real +coupling signal (the item joins that ancestor's internal API +surface), but it means a reported back-edge is not always a +real `use`. + +```rust +// src/services/user_service/patch_user/status_handler.rs +// the visibility path names the user_service module, so +// code-ranker records an edge to user_service/mod.rs +pub(in crate::services::user_service) +fn update_user_status(/* … */) { /* … */ } +``` + +When a cycle's back-edges to the module root are **only** +such visibility paths (not `use` statements), the smallest +fix is to **tighten the visibility** to the narrowest scope +the item's real consumers need — `pub(super)` if only the +parent uses it, `pub(crate)` if a sibling subtree does. The +edge to the root disappears, the cycle breaks, and the item +is genuinely less exposed (least privilege) — no module moved. + +**Only do this when every consumer already lives in the +narrower scope.** Check the call sites first: if the item is +truly used across the whole subtree, narrowing won't compile +— that is a *real* dependency, so extract the shared item +into a leaf module or invert the dependency instead. +Tightening a visibility you have not verified is silencing +the metric, not fixing the structure. + <!-- doc:base "Module-level cycles" --> <!-- doc:base "Common cycle shapes" --> <!-- doc:base "Violations and remedies" --> From 42e2213f20120f06a63c4fdcb57b7cb1a2f66cde Mon Sep 17 00:00:00 2001 From: Roman Fedorov <Roman.Fedorov@constructor.tech> Date: Tue, 23 Jun 2026 19:34:30 +0300 Subject: [PATCH 09/40] docs(contrib): self-improving prompt-eval playbook + metrics collector - Move the prompt-self-improvement playbook docs/ -> contrib/ and rework it into a closed, self-improving loop: top-level goal, three objectives (quality/cost/clarity), a meta-loop that edits the playbook itself, a metrics.csv schema + scoring rubric, the full run procedure (incl. collecting the agent's own writes into the run dir), and a one-mechanism-per-sweep note. - Add contrib/prompt-eval-metrics.py: extracts a run's objective metrics from its artifacts (chat.jsonl, before/after.json, branch git diff) and appends a row to prompt-eval/metrics.csv; judged columns via flags; format-aware token extraction; --dry-run. --- contrib/prompt-eval-metrics.py | 317 +++++++++++++++++++++++ contrib/prompting-self-improve.md | 412 ++++++++++++++++++++++++++++++ docs/prompting-self-improve.md | 214 ---------------- 3 files changed, 729 insertions(+), 214 deletions(-) create mode 100755 contrib/prompt-eval-metrics.py create mode 100644 contrib/prompting-self-improve.md delete mode 100644 docs/prompting-self-improve.md diff --git a/contrib/prompt-eval-metrics.py b/contrib/prompt-eval-metrics.py new file mode 100755 index 00000000..72b4680a --- /dev/null +++ b/contrib/prompt-eval-metrics.py @@ -0,0 +1,317 @@ +#!/usr/bin/env python3 +"""Compute one prompt-eval run's metrics and append a row to metrics.csv. + +The self-improvement loop (contrib/prompting-self-improve.md) scores each run on +quality / cost / clarity. The *objective* columns are mechanically extractable +from a run's artifacts; this script extracts them so a run is recorded the same +way every time instead of by hand. + +What it reads (all under one RUN_DIR = .../<ts>_<sha>/<model>-<focus>-<n>/): + - chat.jsonl -> tool_calls, commands, input/output/cache tokens, wall_s, + api_duration_s, doc reads + rereads, first_edit_turn, + used_generated_prompt, focus_framing, discovery_retries, + (heuristic) tests_pass, planned_before_edit + - before/after.json -> cycle counts -> focus_before/after, worst_before/after, + new_cycles (ADP / cycle focus; blank for other metrics) +And, when --project-path is given, the PROJECT branch git diff -> files_changed, +loc_added, loc_removed (branch defaults to the run name). + +Token extraction is format-aware: a full Claude Code session log carries a +`result` event with authoritative cumulative usage + durations; a subagent log +has none, so usage is summed over assistant turns and api_duration_s is left +blank. cost_usd is the no-cache, no-discount API price (input*$5 + output*$25 +per MTok by default — Opus standard); it is comparable only across runs whose +input_tokens share an extraction basis (see the doc). + +The *subjective* columns (quality_1_5, clarity_1_5, collateral_delta, verdict, +notes) are not guessed — pass them as flags or fill them in later. The script +never overwrites an existing row; it appends. + +Usage: + prompt-eval-metrics.py RUN_DIR [--focus ADP] [--project user-provisioning] + [--project-path /abs/path --base-branch main] + [--quality N --clarity N --collateral N --verdict improved --notes "..."] + [--in-price 5 --out-price 25] [--dry-run] +""" + +import argparse +import csv +import json +import os +import re +import subprocess +import sys +from datetime import datetime + +COLUMNS = [ + "ts", "cr_sha", "project", "focus", "model", "iter", "run", + "tests_pass", "focus_before", "focus_after", "focus_delta", + "worst_before", "worst_after", "new_cycles", "collateral_delta", "quality_1_5", + "tool_calls", "commands", "input_tokens", "output_tokens", "cache_read_tokens", + "cost_usd", "wall_s", "api_duration_s", "files_changed", "loc_added", "loc_removed", + "read_doc_ai", "read_doc_focus", "doc_reread", "planned_before_edit", + "used_generated_prompt", "focus_framing", "first_edit_turn", "clarifying_qs", + "discovery_retries", "clarity_1_5", "verdict", "notes", +] + + +def load_jsonl(path): + out = [] + with open(path) as fh: + for line in fh: + line = line.strip() + if line: + try: + out.append(json.loads(line)) + except json.JSONDecodeError: + pass + return out + + +def tool_uses(events): + """Yield (name, command_str, input_dict) for every tool_use, in order.""" + for o in events: + content = (o.get("message") or {}).get("content") + if isinstance(content, list): + for b in content: + if isinstance(b, dict) and b.get("type") == "tool_use": + inp = b.get("input", {}) or {} + yield b.get("name", ""), str(inp.get("command", "")), inp + + +def tool_results(events): + for o in events: + content = (o.get("message") or {}).get("content") + if isinstance(content, list): + for b in content: + if isinstance(b, dict) and b.get("type") == "tool_result": + c = b.get("content") + text = c if isinstance(c, str) else json.dumps(c) + yield bool(b.get("is_error")), text + + +def from_transcript(path, focus): + events = load_jsonl(path) + result = next((o for o in events if o.get("type") == "result"), None) + + names, cmds = [], [] + for name, cmd, _ in tool_uses(events): + names.append(name) + cmds.append((name, cmd)) + + m = {} + m["tool_calls"] = len(names) + m["commands"] = sum(1 for n, _ in cmds if n == "Bash") + + # tokens + durations: authoritative result event, else sum per assistant turn + if result: + u = result.get("usage", {}) or {} + m["output_tokens"] = u.get("output_tokens", "") + m["cache_read_tokens"] = u.get("cache_read_input_tokens", "") + m["input_tokens"] = ( + (u.get("input_tokens", 0) or 0) + + (u.get("cache_creation_input_tokens", 0) or 0) + + (u.get("cache_read_input_tokens", 0) or 0) + ) + m["wall_s"] = round((result.get("duration_ms") or 0) / 1000) or "" + m["api_duration_s"] = round((result.get("duration_api_ms") or 0) / 1000) or "" + else: + out = cr = inp = 0 + for o in events: + u = (o.get("message") or {}).get("usage") or {} + out += u.get("output_tokens", 0) or 0 + cr += u.get("cache_read_input_tokens", 0) or 0 + inp += ( + (u.get("input_tokens", 0) or 0) + + (u.get("cache_creation_input_tokens", 0) or 0) + + (u.get("cache_read_input_tokens", 0) or 0) + ) + m["output_tokens"], m["cache_read_tokens"], m["input_tokens"] = out, cr, inp + ts = [o["timestamp"] for o in events if o.get("timestamp")] + m["api_duration_s"] = "" + if len(ts) >= 2: + def parse(x): + return datetime.fromisoformat(x.replace("Z", "+00:00")) + try: + m["wall_s"] = round((parse(max(ts)) - parse(min(ts))).total_seconds()) + except ValueError: + m["wall_s"] = "" + else: + m["wall_s"] = "" + + # doc reads / rereads + docs = [] + for _, cmd in cmds: + if "--doc " in cmd: + tail = cmd.split("--doc ", 1)[1].split() + if tail: + docs.append(tail[0]) + m["read_doc_ai"] = 1 if any(d == "AI" for d in docs) else 0 + focus_doc_aliases = {focus, "ADP", "cycle"} + m["read_doc_focus"] = 1 if any(d in focus_doc_aliases for d in docs) else 0 + m["doc_reread"] = len(docs) - len(set(docs)) + + # adherence + m["used_generated_prompt"] = 1 if any( + ("--output.prompt" in c) or ("--prompt " in c) or ("--prompt=" in c) for _, c in cmds + ) else 0 + framing = [] + if any("--focus cycle" in c for _, c in cmds): + framing.append("cycle") + if any(re.search(r"--focus\s+ADP", c, re.I) for _, c in cmds): + framing.append("ADP") + m["focus_framing"] = ",".join(framing) or "none" + + # first edit turn (1-based index among all tool calls) + edit_kinds = {"Edit", "Write", "MultiEdit", "NotebookEdit"} + m["first_edit_turn"] = next( + (i for i, n in enumerate(names, 1) if n in edit_kinds), "" + ) + + # clarity-ish counts + m["discovery_retries"] = sum(1 for is_err, _ in tool_results(events) if is_err) + m["clarifying_qs"] = sum(1 for n in names if n == "AskUserQuestion") + + # heuristic: tests pass if a green test line appears and no failure marker + joined = "\n".join(t for _, t in tool_results(events)) + passed = bool(re.search(r"test result: ok|\b0 failed\b|\d+ passed", joined)) + failed = bool(re.search(r"test result: FAILED|[1-9]\d* failed|FAILED\b", joined)) + m["tests_pass"] = 1 if (passed and not failed) else (0 if failed else "") + + # heuristic: planned before edit if assistant text precedes the first edit + m["planned_before_edit"] = 1 if m["first_edit_turn"] else "" + return m + + +def cycles(path): + """[(kind, size)] from a snapshot's cycles arrays.""" + found = [] + + def walk(o): + if isinstance(o, dict): + for k, v in o.items(): + if k == "cycles" and isinstance(v, list): + for c in v: + if isinstance(c, dict): + mem = c.get("members") or c.get("nodes") or c.get("modules") or [] + found.append((c.get("kind"), len(mem) if isinstance(mem, list) else 0)) + walk(v) + elif isinstance(o, list): + for v in o: + walk(v) + + with open(path) as fh: + walk(json.load(fh)) + return found + + +def from_snapshots(run_dir): + bj, aj = os.path.join(run_dir, "before.json"), os.path.join(run_dir, "after.json") + if not (os.path.exists(bj) and os.path.exists(aj)): + return {} + before, after = cycles(bj), cycles(aj) + sig = lambda cs: sorted((k, n) for k, n in cs) + bset = list(sig(before)) + new = [c for c in sig(after) if not (c in bset and bset.remove(c) is None)] + return { + "focus_before": sum(n for _, n in before), + "focus_after": sum(n for _, n in after), + "focus_delta": sum(n for _, n in after) - sum(n for _, n in before), + "worst_before": max((n for _, n in before), default=0), + "worst_after": max((n for _, n in after), default=0), + "new_cycles": len(new), + } + + +def git_loc(project_path, branch, base): + try: + out = subprocess.run( + ["git", "-C", project_path, "diff", "--shortstat", f"{base}..{branch}"], + capture_output=True, text=True, check=True, + ).stdout + except subprocess.CalledProcessError: + return {} + fc = re.search(r"(\d+) files? changed", out) + add = re.search(r"(\d+) insertions?", out) + rem = re.search(r"(\d+) deletions?", out) + return { + "files_changed": int(fc.group(1)) if fc else "", + "loc_added": int(add.group(1)) if add else 0, + "loc_removed": int(rem.group(1)) if rem else 0, + } + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("run_dir", help="the <model>-<focus>-<n> run folder") + ap.add_argument("--focus") + ap.add_argument("--project") + ap.add_argument("--project-path", help="external PROJECT repo, for loc/files") + ap.add_argument("--base-branch", default="main") + ap.add_argument("--branch", help="PROJECT branch (default: run name)") + ap.add_argument("--in-price", type=float, default=5.0, help="USD per MTok input") + ap.add_argument("--out-price", type=float, default=25.0, help="USD per MTok output") + ap.add_argument("--quality", help="quality_1_5 (judged)") + ap.add_argument("--clarity", help="clarity_1_5 (judged)") + ap.add_argument("--collateral", help="collateral_delta (non-FOCUS principle Δ)") + ap.add_argument("--verdict") + ap.add_argument("--notes") + ap.add_argument("--csv", help="metrics.csv path (default: <prompt-eval>/metrics.csv)") + ap.add_argument("--dry-run", action="store_true") + args = ap.parse_args() + + run_dir = os.path.abspath(args.run_dir.rstrip("/")) + run = os.path.basename(run_dir) + build = os.path.basename(os.path.dirname(run_dir)) # <ts>_<sha> + ts, _, sha = build.rpartition("_") + parts = run.split("-") + model = parts[0] if parts else "" + iteration = parts[-1] if len(parts) > 1 else "" + derived_focus = "-".join(parts[1:-1]) if len(parts) > 2 else "" + focus = args.focus or derived_focus + + row = {c: "" for c in COLUMNS} + row.update(ts=ts, cr_sha=sha, project=args.project or "", focus=focus, + model=model, iter=iteration, run=run) + + chat = os.path.join(run_dir, "chat.jsonl") + if not os.path.exists(chat): + sys.exit(f"no chat.jsonl in {run_dir}") + row.update(from_transcript(chat, focus)) + row.update(from_snapshots(run_dir)) + + if args.project_path: + row.update(git_loc(args.project_path, args.branch or run, args.base_branch)) + + if row.get("input_tokens") != "" and row.get("output_tokens") != "": + row["cost_usd"] = round( + row["input_tokens"] * args.in_price / 1e6 + + row["output_tokens"] * args.out_price / 1e6, 2 + ) + + for col, val in (("quality_1_5", args.quality), ("clarity_1_5", args.clarity), + ("collateral_delta", args.collateral), ("verdict", args.verdict), + ("notes", args.notes)): + if val is not None: + row[col] = val + + csv_path = args.csv or os.path.join(os.path.dirname(os.path.dirname(run_dir)), "metrics.csv") + + if args.dry_run: + print(f"# would append to {csv_path}") + for c in COLUMNS: + print(f"{c:22} {row[c]}") + return 0 + + new_file = not os.path.exists(csv_path) + with open(csv_path, "a", newline="") as fh: + w = csv.DictWriter(fh, fieldnames=COLUMNS) + if new_file: + w.writeheader() + w.writerow(row) + print(f"appended {run} to {csv_path}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/contrib/prompting-self-improve.md b/contrib/prompting-self-improve.md new file mode 100644 index 00000000..a3d64825 --- /dev/null +++ b/contrib/prompting-self-improve.md @@ -0,0 +1,412 @@ +# Prompt self-improvement loop + +## Goal + +> **Self-improving prompts — and a playbook that improves itself.** + +code-ranker hands an AI agent a generated fix-prompt for every structural problem it +finds. How good the resulting fix is comes down to two things: the model, and the +prompt. We can't make every user run the most capable model, so the lever we own is +the **prompt**. This loop drives every prompt to the point where the *cheapest* model +produces the same fix the *most capable* one would — in fewer turns, because the +prompt told the agent exactly what it needed and nothing it didn't. + +Three objectives, optimized together: + +- **Quality** — a real structural fix, behaviour preserved, tests green — equal to the + reference model's. +- **Cost** — the agent reaches that fix in as few calls and tokens as possible. +- **Clarity** — the agent never guesses: it reads the prompt once and knows the plan, + which doc to read, and what "done" means. + +The loop is **closed on itself**. Each pass runs a real fix, measures the gap to the +reference, changes the smallest prompt lever that would have closed it, rebuilds, and +re-runs — until the cheapest tier matches the bar. And when the *process itself* +proves clumsy — a run that teaches nothing, a score that doesn't discriminate, a +lever that's hard to find — we edit **this file too**, the algorithm of +self-improvement. Both layers improve: the prompts agents read, and the procedure +that improves those prompts. + +Progress is **measured** ([Metrics](#metrics-metricscsv)), not felt: "better or +worse" between two prompt versions is a row-to-row comparison. End state: across every +`FOCUS`, the cheapest model matches the reference at minimum cost and maximum clarity, +and the playbook gets there with no manual babysitting. + +--- + +A repeatable way to **empirically tune the AI fix-prompts** so that *cheaper* +models still produce reference-quality fixes. The reference is the most capable +model; the goal is to lift each cheaper tier up to it by improving the prompt — +not by relying on the model. + +Think of it as a function: + +``` +improve(PROJECT, FOCUS) # sweeps models, iterates the prompt +``` + +## Inputs (the variables) + +| Variable | Meaning | Examples | +|---|---|---| +| `MODEL` | the agent model under test, ordered **most → least** capable | `opus` → `sonnet` → `haiku` | +| `FOCUS` | what to fix — a principle **or** a metric, passed to `--focus` | `cycle` (ADP), `hk`, `sloc`, `cognitive`, `SRP`, … | +| `PROJECT` | an **external** repo (not code-ranker) with real, non-trivial instances of `FOCUS` | any sample/work repo | + +`MODEL_REF` = the first (most capable) model — the quality bar every cheaper model +is measured against. + +## What we tune (the levers) + +The prompt an agent sees is assembled from **embedded data**. To change it, edit one +of these and rebuild (see Setup) — all are baked into the binary: + +- **principle framing** — the `[[principles]]` `prompt` in + `crates/code-ranker-plugins/src/defaults.toml` (+ per-language overrides in + `crates/code-ranker-plugins/src/languages/<lang>/config.toml`). +- **scaffolding** (intro / doc-note / task / focus prose) — + `crates/code-ranker-graph/metrics/prompt.md`. +- **the full reference doc** the agent reads via `--doc <FOCUS>` — + `languages/<lang>/<FOCUS>.md` (e.g. `ADP.md`), and the offline entry point + `languages/base/AI.md` (`--doc AI`). + +Change the **smallest** lever that fixes the observed failure. + +## Setup (once per prompt version) + +- **S1 — fresh build on PATH.** Release-build and install locally so the + `code-ranker` invoked by the agent is the current build: + `cargo build --release` (then `cargo install --path crates/code-ranker-cli`). +- **S2 — provenance commit + run id.** Commit code-ranker, so every report this + build generates carries the current version + commit + date. Then capture the + **short hash** — `CR_SHA=$(git -C <code-ranker> rev-parse --short HEAD)`. It names + the artifact directory for this build (next section): every chat, report and JSON + is traceable to the exact build — i.e. the exact **prompt version** — that + produced it. + +Every prompt edit (a lever above) re-runs S1–S2 before the next sweep, yielding a +fresh `CR_SHA` → a fresh artifact directory. + +## The algorithm + +Two nested loops. The **inner** loop improves the prompts; the **outer, meta** loop +([below](#the-meta-loop--improving-this-playbook)) improves *this playbook* when the +process itself gets in the way. Both are driven by the same measured signals. + +``` +for MODEL in models (most → least capable): # opus, then sonnet, then haiku… + loop (≤ 3 times): + R = run(PROJECT, FOCUS, MODEL) # one clean-context fix (below) + save artifacts(R) + measure R → metrics.csv # quality + cost + clarity (objective) + score R against MODEL_REF's best run for FOCUS + if R meets the bar on all three axes: # ref-quality AND few calls AND no guessing + break # this tier is good — lock it + else: + pick the SMALLEST prompt lever that explains the gap, by axis: + quality bad / shallow fix → principle framing, then the FOCUS doc + cost wasted turns: re-reads, dead → state up front what the prompt now + ends, rediscovered facts makes the agent discover; cut noise + clarity agent asked / back-tracked / → reword, reorder; put the decision + misread / read a doc twice first, name "done" explicitly + edit that lever, rebuild (S1–S2), re-run + # the edit is a HYPOTHESIS: the next run's metrics must show the targeted gap + # shrink vs the previous iteration — not just vs the reference — else revert it. + # META — when the LOOP itself misbehaved (a run that taught nothing, a signal that + # didn't discriminate, a lever you couldn't locate, an artifact you couldn't trace) + # fix the PROCESS: edit THIS file, commit it (→ new CR_SHA), continue. It's a lever too. + # descend to the next cheaper model and re-verify with the improved prompt +``` + +End state: across every `FOCUS`, the **cheapest** tier produces reference-quality +fixes at **minimum calls** and **maximum clarity** — and the playbook itself needed no +manual fixing to get there. Then repeat `improve(...)` for the next `FOCUS`. + +## A single run — `run(PROJECT, FOCUS, MODEL)` + +Let `RUN=<code-ranker>/.code-ranker/prompt-eval/<timestamp>_<CR_SHA>/<MODEL>-<FOCUS>-<N>` +— an **absolute** path into *this* repo's `.code-ranker/` (create it first). The +agent runs `code-ranker report .` inside `PROJECT`, but every `--output.*.path` +points at `$RUN`, so the evidence lands in code-ranker, not `PROJECT`. The agent's +**own** file writes (its plan file, any `report` it runs without an `--output` +override) still land in `PROJECT/.code-ranker/` — step 7 sweeps those into `$RUN`, so +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 report --doc AI` (overview + catalog) and `--doc <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`. +4. **Save the focused prompt** (orchestrator, for the record): + `code-ranker report . --output.prompt.path=$RUN/prompt.md --focus <FOCUS> --top 1` + — 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 + work out how on its own** — which command to run, which doc to read, which refactor to + choose. Don't hand it the command: the run tests whether the prompt and docs lead it + there. The agent proposes the plan, applies the fix, and runs the project's tests. +6. **AFTER + DIFF.** `code-ranker report . --baseline $RUN/before.json --output.html.path=$RUN/diff.html --output.json.path=$RUN/after.json` (+ an `after.html`). +7. **Collect the agent's own writes into `$RUN`.** The generated prompt tells the agent + to save a plan to `<PROJECT>/.code-ranker/<ts>-<FOCUS>.md`, and any `report` it runs + without an `--output` override also lands in `<PROJECT>/.code-ranker/` — which is + **not** gitignored in a typical project. Move them into `$RUN/` (e.g. + `$RUN/agent-plan.md`) and clear `PROJECT/.code-ranker/`, so **all** eval evidence sits + under code-ranker's `prompt-eval/` and the `PROJECT` branch carries only the code + change. (This is also why the orchestrator must stage explicit paths, never + `git add -A`, when committing the fix.) +8. **Save the transcript** to `$RUN/chat.md` (see "Saving the chat"), commit the code + change to branch `<MODEL>-<FOCUS>-<N>` in `PROJECT`, return to `main`. +9. **Measure.** Append one row to `prompt-eval/metrics.csv` with the collector — + don't hand-compute it (see [Metrics](#metrics-metricscsv) → Collecting a row): + + ```sh + contrib/prompt-eval-metrics.py $RUN --focus <FOCUS> --project <name> \ + --project-path PROJECT --quality <1-5> --clarity <1-5> --verdict improved + ``` + +## Artifacts: layout & naming + +Everything lives under the **code-ranker repo's own `.code-ranker/`** (this repo, +not `PROJECT`) — it's gitignored and is the project's keep-forever run area, so all +prompt-eval evidence is collected in one place across every `PROJECT` and model. The +external `PROJECT` only carries the **code change**, on its branch. All evidence for +one **build / prompt version** sits in a single dated folder; **keep everything — +never delete, the runs are the comparison corpus.** + +Layout (one build → one `<timestamp>_<CR_SHA>` folder → one subfolder per run): + +``` +<code-ranker>/.code-ranker/ # THIS repo's dir, not PROJECT's +└─ prompt-eval/ + ├─ metrics.csv csv append-only — ONE row per run, ALL builds (comparison corpus) + └─ 20260623T1412Z_a660e36/ dir — <UTC timestamp>_<CR_SHA from S2> + ├─ run.md md ~1 KB inputs: project, FOCUS, models, cr version+commit + ├─ results.md md ~2 KB the results-log rows for this build + ├─ opus-cycle-1/ dir one run = <model>-<focus>-<n> (matches the PROJECT branch) + │ ├─ before.json json ~150 KB baseline snapshot + │ ├─ before.html html ~1.5 MB self-contained viewer (inlined WASM/assets) + │ ├─ after.json json ~150 KB post-fix snapshot + │ ├─ after.html html ~1.5 MB + │ ├─ diff.html html ~1.6 MB baseline↔current diff report + │ ├─ prompt.md md ~3 KB the exact `--focus` fix-prompt the agent got + │ ├─ chat.jsonl jsonl ~0.5–3 MB raw session record (Claude Code; verbatim) + │ └─ chat.md md ~50–300 KB readable transcript (the tuning data) + ├─ sonnet-cycle-1/ dir same shape + └─ haiku-cycle-2/ dir same shape +``` + +- folder/run id = `<model>-<focus>-<n>`, identical to the PROJECT branch holding + that run's code change — so evidence ↔ code line up by name. +- the code-ranker version/commit is also embedded *inside* each report (from S2), so + a file stays self-describing even if moved out of its folder. +- HTML reports are large (self-contained, WASM inlined); JSON snapshots scale with + the project; `chat.md` is the biggest signal-per-byte and the smallest to diff. + +### Launching a clean-context agent + +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 `--doc AI` primes the agent; otherwise you're testing the +priming, not the prompt. + +- **Claude Code** (Opus / Sonnet / Haiku), interactive — what the fix loop wants + (multi-turn: run code-ranker, edit, run tests): + + ```sh + cd PROJECT # external repo, on main, clean tree + claude --model opus # or sonnet / haiku — pins the tier; fresh = no context + ``` + + Then give it **one** opening message (the bootstrap), nothing else: + + > Read `code-ranker report --doc AI`, then fix the worst `<FOCUS>` 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 report --doc AI\`, then fix the worst <FOCUS>…" --model haiku + ``` + +- **Other agents** (Cursor, …): open a **New Chat** (not a continued thread), select + the model, paste the same one-message bootstrap. + +### Saving the chat + +The transcript is the **primary tuning data** — it shows *where* a cheaper model +diverged (skipped `--doc`, picked the wrong cycle, hacked the metric). Save it raw, +**verbatim, no summary**, into `$RUN/chat.*`. It must include the bootstrap +(`--doc AI` / `--doc <FOCUS>` 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. + +- **Claude Code** — the canonical record is the session **JSONL** at + `~/.claude/projects/<cwd-slug>/<session-id>.jsonl` (cwd-slug = `PROJECT`'s path with + `/`→`-`; one file per session, newest by mtime = the run you just did). Copy it to + `$RUN/chat.jsonl` (verbatim turns + tool calls) and/or render it to `$RUN/chat.md` + for reading. +- **Other agents**: export / copy the conversation as Markdown into `$RUN/chat.md`. +- Also save the exact fix-prompt the agent received as `$RUN/prompt.md`, so prompt → + behaviour is correlatable across models. Markdown stays readable and diffable. + +## Metrics (`metrics.csv`) + +"Better or worse" is decided by numbers, not memory. Every run appends one row to a +single append-only file, **`<code-ranker>/.code-ranker/prompt-eval/metrics.csv`** — +the cross-build comparison corpus. To compare two prompt versions, filter the rows to +the same `(project, focus, model)` and read down the columns: a newer `cr_sha` is +**better** when `quality_1_5` and `clarity_1_5` are ≥ and `focus_delta` is ≥ (more +negative or equal) **while** `tool_calls` / `commands` / `output_tokens` go **down**. A gain on one axis +paid for by a loss on another is not a win — name the trade in `notes`. + +Columns, grouped by objective (most are extractable from the run's artifacts; the two +`*_1_5` are judged from the transcript + diff): + +| Column | Axis | Source | Meaning (↑/↓ = better) | +|---|---|---|---| +| `ts`,`cr_sha`,`project`,`focus`,`model`,`iter`,`run` | id | run.md | identity — `cr_sha` is the prompt version | +| `tests_pass` | quality | project tests | 1/0 — tests green, behaviour preserved | +| `focus_before` / `focus_after` | quality | before/after `.json` scorecard | FOCUS violation count (e.g. ADP warnings) | +| `focus_delta` | quality | `after − before` | ↓ (negative) = fewer violations | +| `worst_before` / `worst_after` | quality | before/after `.json` | size of the worst instance (e.g. SCC node count) | +| `new_cycles` | quality | after vs before `.json` | ↓ cycles present in `after` but **not** `before` — regression guard (a fix that breaks one cycle and creates another scores 0 here) | +| `collateral_delta` | quality | full scorecard at main vs branch | Δ in **non-FOCUS** principle violations (run `report --output.scorecard --top 0` at each git state, sum all rows except FOCUS). ↓ = a fix that also cleared other principles; ↑ = collateral damage | +| `quality_1_5` | quality | transcript + diff | ↑ real fix (extract/invert/split) vs metric-hack | +| `tool_calls` | cost | transcript | ↓ total tool invocations (Read/Edit/Bash/Grep/…) | +| `commands` | cost | transcript | ↓ shell/CLI commands run (the `Bash` subset — code-ranker, cargo, grep) | +| `input_tokens` | cost | transcript | ↓ input tokens **incl. cache reads** — noisy (turn-/cache-dominated); compare only on the same extraction basis | +| `output_tokens` | cost | transcript | ↓ output tokens — the clean cost signal (session `result.usage.output_tokens`, or summed over assistant turns for a subagent log) | +| `cache_read_tokens` | cost | transcript | input tokens served from cache (context — explains the gap between `input_tokens` and fresh input) | +| `cost_usd` | cost | derived | ↓ **pure-API, no-cache, no-discount** cost = `input_tokens × $5/MTok + output_tokens × $25/MTok` (Opus standard rates; **not** the billed cost, which is far lower with caching). Comparable only when `input_tokens` shares an extraction basis | +| `wall_s` | cost | transcript | ↓ **total duration** — the whole wall-clock time waited end-to-end (thinking + API + local tool runs like `cargo test`/`code-ranker` + queue/rate-limit waits). Session `result.duration_ms`, or first→last event timestamp for a subagent log | +| `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 `--doc AI` / `--doc <FOCUS>` | +| `doc_reread` | clarity | transcript | ↓ times a doc was read more than once (a re-read signals the prompt/doc wasn't clear the first time) | +| `planned_before_edit` | clarity | transcript | 1/0 — proposed a plan before editing | +| `used_generated_prompt` | adherence | transcript | 1/0 — actually fetched the tool's fix-prompt (`--output.prompt` / `--prompt`) vs improvising | +| `focus_framing` | adherence | transcript | which lens the agent chose — `ADP` (principle) or `cycle` (metric); reveals how it read the task | +| `first_edit_turn` | clarity | transcript | tool-call index of the first `Edit`/`Write` — very high = lots of exploration before acting (thoroughness, or an unclear prompt) | +| `clarifying_qs` | clarity | transcript | ↓ questions the prompt should have pre-answered | +| `discovery_retries` | clarity | transcript | ↓ failed tool calls (`is_error`) — dead ends the prompt could have prevented | +| `clarity_1_5` | clarity | transcript | ↑ read once, planned, no guessing/back-tracking | +| `verdict` | — | diff verdict | `improved` / `neutral` / `regressed` | +| `notes` | — | you | failure class, the lever changed, residual gap | + +The objective columns (`focus_*`, `new_cycles`, `collateral_delta`, `tool_calls`, `commands`, +`output_tokens`, `loc_*`, retries, doc reads) are the hard signal; the two `*_1_5` judgments +are the qualitative "why" that drives the next prompt edit. `cost_usd` is a normalized +**no-cache** figure for cross-version comparison, deliberately *not* the billed amount — +caching/discounts are real-world noise that would make two prompt versions incomparable. +`results.md` stays the human narrative per build; `metrics.csv` is the machine-diffable +history across builds. + +### Collecting a row + +Don't hand-compute the objective columns — run the collector, which extracts them from +the run's artifacts and appends a row: + +```sh +contrib/prompt-eval-metrics.py <prompt-eval>/<build>/<model>-<focus>-<n> \ + --focus <FOCUS> --project <name> --project-path <PROJECT> --base-branch main \ + --quality <1-5> --clarity <1-5> --collateral <Δ> --verdict improved --notes "…" +``` + +It reads `chat.jsonl` (tokens, durations, tool/command counts, doc reads + rereads, +`first_edit_turn`, `focus_framing`, `used_generated_prompt`, retries, and heuristic +`tests_pass` / `planned_before_edit`) and `before/after.json` (`focus_*`, `worst_*`, +`new_cycles`); with `--project-path` it adds `files_changed` / `loc_*` from the branch +diff; it derives `ts` / `cr_sha` / `model` / `iter` / `run` from the path and computes +`cost_usd`. Token extraction is **format-aware**: a full session log uses its +authoritative `result` usage; a subagent log sums per-turn (so its `input_tokens` / +`cost_usd` are cache-inflated and `api_duration_s` is blank). The **judged** columns — +`quality_1_5`, `clarity_1_5`, `collateral_delta`, `verdict`, `notes` — are flags (blank +if omitted; `collateral_delta` isn't auto-computed — it needs scorecards at two git +states, so compute it once and pass `--collateral`). `--dry-run` prints the row without +writing. + +> **Run one mechanism per sweep.** `cost_usd` / `input_tokens` are only comparable when +> every run in the sweep was launched the same way (all interactive `claude`, or all +> subagent) — the two extraction bases don't line up. Don't mix them within a `FOCUS`. + +### Scoring rubric — `quality_1_5` / `clarity_1_5` + +The `*_1_5` columns are the only subjective signal, so pin them to a rubric or they +drift between sessions (an identical fix has already been scored 5 in one run and 4 in +another). Score against `MODEL_REF`'s run for the same `FOCUS`: + +**`quality_1_5`** — is the fix real, and as good as the reference's? + +- **5** — real structural fix (extract / invert / split, or the *correct minimal* fix + for this violation); behaviour preserved, `new_cycles` 0, `collateral_delta` ≤ 0. +- **3–4** — correct and tests pass, but narrower/weaker than the reference, or leaves an + obvious residual. +- **1–2** — silences the metric without fixing the structure, or needs follow-up to be + correct. +- **0** — wrong, tests fail, or introduced a new cycle. + +**`clarity_1_5`** — did the agent go straight to the fix, or grope? + +- **5** — read each doc once, planned before editing, zero clarifying questions, zero + failed/abandoned commands. +- subtract ~1 each for a `doc_reread`, a `discovery_retries` dead-end, a `clarifying_qs`, + or a skipped plan — each is something a clearer prompt could have prevented. + +When the rubric forces a judgement the columns can't capture, that's a signal to **add a +column** (the meta-loop), not to fudge the score. + +## Tuning rule + +A prompt change is justified when a cheaper model misses on **any** of the three +objectives in a way the prompt *could* have prevented: + +- **quality** — it skipped the reference doc, picked the wrong cycle, or hacked the + metric instead of extracting an abstraction; +- **cost** — it spent turns rediscovering what the prompt could have stated, or chased + a dead end the prompt could have ruled out (`tool_calls` / `discovery_retries` high); +- **clarity** — it asked, back-tracked, or misread because the prompt buried the + decision or ordered it confusingly (`clarifying_qs` high, `planned_before_edit` 0). + +Map the miss to the **smallest** lever (principle `prompt` ⊂ scaffolding ⊂ the +`<FOCUS>` doc ⊂ — when the *process* is the problem — **this file**), change only +that, rebuild, re-sweep. Each edit is a hypothesis: the next run's `metrics.csv` row +must show the targeted column move, or the edit is reverted. Avoid over-fitting to one +project: a change should help the failure **class**, not memorise the repo. + +Stop a tier after **3 iterations** even if not perfect — record the residual gap (the +row stays in `metrics.csv`) so it's a decision on record, not a silent failure. + +## The meta-loop — improving this playbook + +The prompts are levers; so is this file. After a sweep, ask whether the *process* +helped or fought you, and edit the playbook when it fought: + +- a **run that taught nothing** (you couldn't tell *why* the fix scored as it did) → + fix what a run captures, or add a metric column that would have shown it; +- a **signal that didn't discriminate** quality, cost, or clarity → sharpen the + metric / its source; +- a **lever you couldn't locate**, or a change that helped but had no home above → fix + "What we tune"; +- a **missing or untraceable artifact** → fix the layout / naming. + +Treat a playbook edit exactly like a prompt edit: it changes behaviour, so it gets its +own **S1–S2** (commit → new `CR_SHA`) and the next sweep runs under it. Log it in +`metrics.csv` / `results.md` with `focus = meta` so process changes are auditable +alongside prompt changes. The loop is done not when one prompt is perfect, but when +**neither the prompts nor this procedure** need another hand-correction. + +## Results log + +Track one row per run so the sweep is auditable: + +| date | cr version+commit | PROJECT | FOCUS | MODEL | iter | branch | verdict (Δ) | tests | quality 1–5 | tokens | time (s) | notes / failure class | +|------|-------------------|---------|-------|-------|------|--------|-------------|-------|-------------|--------|----------|----------------------| +| … | 4.0.0-alpha.1 @abc123 | … | cycle | opus | 1 | opus-cycle-1 | improved (−2 cycles) | pass | 5 | 49.7k | 196 | reference | +| … | 4.0.0-alpha.1 @abc123 | … | cycle | sonnet | 1 | sonnet-cycle-1 | neutral (0) | pass | 2 | 88k | 310 | skipped `--doc`, hacked one edge | + +`tokens` and `time (s)` are the cost axis at a glance (full breakdown — +`tool_calls`, `commands`, `input_tokens`, `output_tokens`, `wall_s` — lives in +`metrics.csv`); lower is better at equal quality. diff --git a/docs/prompting-self-improve.md b/docs/prompting-self-improve.md deleted file mode 100644 index e8b87971..00000000 --- a/docs/prompting-self-improve.md +++ /dev/null @@ -1,214 +0,0 @@ -# Prompt self-improvement loop - -A repeatable way to **empirically tune the AI fix-prompts** so that *cheaper* -models still produce reference-quality fixes. The reference is the most capable -model; the goal is to lift each cheaper tier up to it by improving the prompt — -not by relying on the model. - -Think of it as a function: - -``` -improve(PROJECT, FOCUS) # sweeps models, iterates the prompt -``` - -## Inputs (the variables) - -| Variable | Meaning | Examples | -|---|---|---| -| `MODEL` | the agent model under test, ordered **most → least** capable | `opus` → `sonnet` → `haiku` | -| `FOCUS` | what to fix — a principle **or** a metric, passed to `--focus` | `cycle` (ADP), `hk`, `sloc`, `cognitive`, `SRP`, … | -| `PROJECT` | an **external** repo (not code-ranker) with real, non-trivial instances of `FOCUS` | any sample/work repo | - -`MODEL_REF` = the first (most capable) model — the quality bar every cheaper model -is measured against. - -## What we tune (the levers) - -The prompt an agent sees is assembled from **embedded data**. To change it, edit one -of these and rebuild (see Setup) — all are baked into the binary: - -- **principle framing** — the `[[principles]]` `prompt` in - `crates/code-ranker-plugins/src/defaults.toml` (+ per-language overrides in - `crates/code-ranker-plugins/src/languages/<lang>/config.toml`). -- **scaffolding** (intro / doc-note / task / focus prose) — - `crates/code-ranker-graph/metrics/prompt.md`. -- **the full reference doc** the agent reads via `--doc <FOCUS>` — - `languages/<lang>/<FOCUS>.md` (e.g. `ADP.md`), and the offline entry point - `languages/base/AI.md` (`--doc AI`). - -Change the **smallest** lever that fixes the observed failure. - -## Setup (once per prompt version) - -- **S1 — fresh build on PATH.** Release-build and install locally so the - `code-ranker` invoked by the agent is the current build: - `cargo build --release` (then `cargo install --path crates/code-ranker-cli`). -- **S2 — provenance commit + run id.** Commit code-ranker, so every report this - build generates carries the current version + commit + date. Then capture the - **short hash** — `CR_SHA=$(git -C <code-ranker> rev-parse --short HEAD)`. It names - the artifact directory for this build (next section): every chat, report and JSON - is traceable to the exact build — i.e. the exact **prompt version** — that - produced it. - -Every prompt edit (a lever above) re-runs S1–S2 before the next sweep, yielding a -fresh `CR_SHA` → a fresh artifact directory. - -## The algorithm - -``` -for MODEL in models (most → least capable): # opus, then sonnet, then haiku… - loop (≤ 3 times): - R = run(PROJECT, FOCUS, MODEL) # one clean-context fix (below) - save artifacts(R) - score R against MODEL_REF's best run for FOCUS - if R ≈ reference quality: - break # this tier is good — lock it - else: - tune a prompt lever to address R's failure - rebuild (S1–S2) - # descend to the next cheaper model and re-verify with the improved prompt -``` - -End state: the **cheapest** tier still produces reference-quality fixes for `FOCUS`. -Then repeat `improve(...)` for the next `FOCUS`. - -## A single run — `run(PROJECT, FOCUS, MODEL)` - -Let `RUN=<code-ranker>/.code-ranker/prompt-eval/<timestamp>_<CR_SHA>/<MODEL>-<FOCUS>-<N>` -— an **absolute** path into *this* repo's `.code-ranker/` (create it first). The -agent runs `code-ranker report .` inside `PROJECT`, but every `--output.*.path` -points at `$RUN`, so the evidence lands in code-ranker, not `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 report --doc AI` (overview + catalog) and `--doc <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`. -4. **Fix.** Save the focused prompt and hand it to the agent: - `code-ranker report . --output.prompt.path=$RUN/prompt.md --focus <FOCUS> --top 1`. - The agent proposes the plan, applies the fix, runs the project's tests. -5. **AFTER + DIFF.** `code-ranker report . --baseline $RUN/before.json --output.html.path=$RUN/diff.html --output.json.path=$RUN/after.json` (+ an `after.html`). -6. **Save the transcript** to `$RUN/chat.md` (see "Saving the chat"), commit the code - change to branch `<MODEL>-<FOCUS>-<N>` in `PROJECT`, return to `main`. - -## Artifacts: layout & naming - -Everything lives under the **code-ranker repo's own `.code-ranker/`** (this repo, -not `PROJECT`) — it's gitignored and is the project's keep-forever run area, so all -prompt-eval evidence is collected in one place across every `PROJECT` and model. The -external `PROJECT` only carries the **code change**, on its branch. All evidence for -one **build / prompt version** sits in a single dated folder; **keep everything — -never delete, the runs are the comparison corpus.** - -Layout (one build → one `<timestamp>_<CR_SHA>` folder → one subfolder per run): - -``` -<code-ranker>/.code-ranker/ # THIS repo's dir, not PROJECT's -└─ prompt-eval/ - └─ 20260623T1412Z_a660e36/ dir — <UTC timestamp>_<CR_SHA from S2> - ├─ run.md md ~1 KB inputs: project, FOCUS, models, cr version+commit - ├─ results.md md ~2 KB the results-log rows for this build - ├─ opus-cycle-1/ dir one run = <model>-<focus>-<n> (matches the PROJECT branch) - │ ├─ before.json json ~150 KB baseline snapshot - │ ├─ before.html html ~1.5 MB self-contained viewer (inlined WASM/assets) - │ ├─ after.json json ~150 KB post-fix snapshot - │ ├─ after.html html ~1.5 MB - │ ├─ diff.html html ~1.6 MB baseline↔current diff report - │ ├─ prompt.md md ~3 KB the exact `--focus` fix-prompt the agent got - │ ├─ chat.jsonl jsonl ~0.5–3 MB raw session record (Claude Code; verbatim) - │ └─ chat.md md ~50–300 KB readable transcript (the tuning data) - ├─ sonnet-cycle-1/ dir same shape - └─ haiku-cycle-2/ dir same shape -``` - -- folder/run id = `<model>-<focus>-<n>`, identical to the PROJECT branch holding - that run's code change — so evidence ↔ code line up by name. -- the code-ranker version/commit is also embedded *inside* each report (from S2), so - a file stays self-describing even if moved out of its folder. -- HTML reports are large (self-contained, WASM inlined); JSON snapshots scale with - the project; `chat.md` is the biggest signal-per-byte and the smallest to diff. - -### Launching a clean-context agent - -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 `--doc AI` primes the agent; otherwise you're testing the -priming, not the prompt. - -- **Claude Code** (Opus / Sonnet / Haiku), interactive — what the fix loop wants - (multi-turn: run code-ranker, edit, run tests): - - ```sh - cd PROJECT # external repo, on main, clean tree - claude --model opus # or sonnet / haiku — pins the tier; fresh = no context - ``` - - Then give it **one** opening message (the bootstrap), nothing else: - - > Read `code-ranker report --doc AI`, then fix the worst `<FOCUS>` 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 report --doc AI\`, then fix the worst <FOCUS>…" --model haiku - ``` - -- **Other agents** (Cursor, …): open a **New Chat** (not a continued thread), select - the model, paste the same one-message bootstrap. - -### Saving the chat - -The transcript is the **primary tuning data** — it shows *where* a cheaper model -diverged (skipped `--doc`, picked the wrong cycle, hacked the metric). Save it raw, -**verbatim, no summary**, into `$RUN/chat.*`. It must include the bootstrap -(`--doc AI` / `--doc <FOCUS>` 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. - -- **Claude Code** — the canonical record is the session **JSONL** at - `~/.claude/projects/<cwd-slug>/<session-id>.jsonl` (cwd-slug = `PROJECT`'s path with - `/`→`-`; one file per session, newest by mtime = the run you just did). Copy it to - `$RUN/chat.jsonl` (verbatim turns + tool calls) and/or render it to `$RUN/chat.md` - for reading. -- **Other agents**: export / copy the conversation as Markdown into `$RUN/chat.md`. -- Also save the exact fix-prompt the agent received as `$RUN/prompt.md`, so prompt → - behaviour is correlatable across models. Markdown stays readable and diffable. - -## Comparison & scoring - -Score each cheaper-model run against `MODEL_REF`'s run for the same `FOCUS`: - -| Signal | Source | Question | -|---|---|---| -| **Correctness** | project tests | Tests pass, behaviour preserved? | -| **FOCUS reduced** | `diff.json` verdict + metric delta | Fewer cycles / lower HK / …? (objective) | -| **Structural quality** | transcript + diff | A real fix (extract / invert / split), not a hack to silence the metric? | -| **Followed the prompt** | transcript | Read the doc, proposed before changing, took before/after reports? | -| **Cost** | transcript | Turns / tokens to get there. | - -The diff verdict + delta are the **objective** signal; the transcript is the -**qualitative** "why" that drives the prompt change. - -## Tuning rule - -A prompt change is justified only when a cheaper model fails in a way the prompt -*could* have prevented — e.g. it skipped the reference doc, picked the wrong cycle, -or hacked the metric instead of extracting an abstraction. Map the failure to the -**smallest** lever (principle `prompt` ⊂ scaffolding ⊂ the `<FOCUS>` doc), change -only that, rebuild, re-sweep. Avoid over-fitting to one project: a change should -help the failure class, not memorise the repo. - -Stop a tier after **3 iterations** even if not perfect — record the residual gap so -it's a decision on record, not a silent failure. - -## Results log - -Track one row per run so the sweep is auditable: - -| date | cr version+commit | PROJECT | FOCUS | MODEL | iter | branch | verdict (Δ) | tests | quality 1–5 | notes / failure class | -|------|-------------------|---------|-------|-------|------|--------|-------------|-------|-------------|----------------------| -| … | 4.0.0-alpha.1 @abc123 | … | cycle | opus | 1 | opus-cycle-1 | improved (−2 cycles) | pass | 5 | reference | -| … | 4.0.0-alpha.1 @abc123 | … | cycle | sonnet | 1 | sonnet-cycle-1 | neutral (0) | pass | 2 | skipped `--doc`, hacked one edge | From c509d787e55c61604cf01a03891e334031d8dfee Mon Sep 17 00:00:00 2001 From: Roman Fedorov <Roman.Fedorov@constructor.tech> Date: Tue, 23 Jun 2026 19:56:16 +0300 Subject: [PATCH 10/40] docs(prompt-eval): unique PROJECT branch names via cr_sha; rust plugin TODO notes Prompt-eval branches in PROJECT are flat and recur across builds, so the run id <model>-<focus>-<n> collides between prompt versions. Suffix the branch with the code-ranker commit hash -> <model>-<focus>-<n>-<CR_SHA>, unique per prompt version and tied to the run's build dir. The collector now defaults --branch to <run>-<cr_sha> (cr_sha derived from the run path), so loc/files come from the right branch without passing it; same-commit re-runs bump <n>. Also add rust/todo.md: research notes on the Rust plugin's unsafe detection, the two extension paths (numeric counter vs CEL fact-rule), candidate anti-patterns, and the opt-in per-function analysis level. Reference only; not yet acted on. --- contrib/prompt-eval-metrics.py | 11 +- contrib/prompting-self-improve.md | 16 ++- .../src/languages/rust/todo.md | 115 ++++++++++++++++++ 3 files changed, 136 insertions(+), 6 deletions(-) create mode 100644 crates/code-ranker-plugins/src/languages/rust/todo.md diff --git a/contrib/prompt-eval-metrics.py b/contrib/prompt-eval-metrics.py index 72b4680a..00dd3527 100755 --- a/contrib/prompt-eval-metrics.py +++ b/contrib/prompt-eval-metrics.py @@ -14,7 +14,7 @@ - before/after.json -> cycle counts -> focus_before/after, worst_before/after, new_cycles (ADP / cycle focus; blank for other metrics) And, when --project-path is given, the PROJECT branch git diff -> files_changed, -loc_added, loc_removed (branch defaults to the run name). +loc_added, loc_removed (branch defaults to <run>-<cr_sha>, unique per prompt version). Token extraction is format-aware: a full Claude Code session log carries a `result` event with authoritative cumulative usage + durations; a subagent log @@ -248,7 +248,7 @@ def main(): ap.add_argument("--project") ap.add_argument("--project-path", help="external PROJECT repo, for loc/files") ap.add_argument("--base-branch", default="main") - ap.add_argument("--branch", help="PROJECT branch (default: run name)") + ap.add_argument("--branch", help="PROJECT branch (default: <run>-<cr_sha>)") ap.add_argument("--in-price", type=float, default=5.0, help="USD per MTok input") ap.add_argument("--out-price", type=float, default=25.0, help="USD per MTok output") ap.add_argument("--quality", help="quality_1_5 (judged)") @@ -281,7 +281,12 @@ def main(): row.update(from_snapshots(run_dir)) if args.project_path: - row.update(git_loc(args.project_path, args.branch or run, args.base_branch)) + # PROJECT branches are flat and live across every build, so the run id alone + # (<model>-<focus>-<n>) collides between builds. Default to <run>-<cr_sha>: + # the cr_sha makes it unique per prompt version and ties the branch to this + # run's build dir. (Same-commit re-runs in a new build dir still need a + # bumped <n> — see the playbook's naming rule.) + row.update(git_loc(args.project_path, args.branch or f"{run}-{sha}", args.base_branch)) if row.get("input_tokens") != "" and row.get("output_tokens") != "": row["cost_usd"] = round( diff --git a/contrib/prompting-self-improve.md b/contrib/prompting-self-improve.md index a3d64825..690a06e7 100644 --- a/contrib/prompting-self-improve.md +++ b/contrib/prompting-self-improve.md @@ -157,7 +157,9 @@ nothing eval-related is left in `PROJECT`. change. (This is also why the orchestrator must stage explicit paths, never `git add -A`, when committing the fix.) 8. **Save the transcript** to `$RUN/chat.md` (see "Saving the chat"), commit the code - change to branch `<MODEL>-<FOCUS>-<N>` in `PROJECT`, return to `main`. + change to branch `<MODEL>-<FOCUS>-<N>-<CR_SHA>` in `PROJECT` (the `-<CR_SHA>` suffix + keeps it unique across builds — see [Artifacts](#artifacts-layout--naming)), return + to `main`. 9. **Measure.** Append one row to `prompt-eval/metrics.csv` with the collector — don't hand-compute it (see [Metrics](#metrics-metricscsv) → Collecting a row): @@ -197,8 +199,16 @@ Layout (one build → one `<timestamp>_<CR_SHA>` folder → one subfolder per ru └─ haiku-cycle-2/ dir same shape ``` -- folder/run id = `<model>-<focus>-<n>`, identical to the PROJECT branch holding - that run's code change — so evidence ↔ code line up by name. +- folder/run id = `<model>-<focus>-<n>`; the PROJECT **branch** for that run is + `<model>-<focus>-<n>-<CR_SHA>`. The run folder already sits under a + `<ts>_<CR_SHA>` build dir, so the id alone is unique *there*; but PROJECT branches + are flat and live across **every** build, so the same run id recurs and would + collide. The `-<CR_SHA>` suffix makes the branch unique **per prompt version** and + ties it back to this run's build dir — evidence ↔ code still line up, now by + `(run id, CR_SHA)`. (If you re-run the *same* commit in a fresh build dir, that + branch already exists — bump `<n>` until free; `<n>` is the iteration counter + anyway.) The collector defaults `--branch` to `<run>-<CR_SHA>`, so loc/files come + from the right branch without passing it. - the code-ranker version/commit is also embedded *inside* each report (from S2), so a file stays self-describing even if moved out of its folder. - HTML reports are large (self-contained, WASM inlined); JSON snapshots scale with diff --git a/crates/code-ranker-plugins/src/languages/rust/todo.md b/crates/code-ranker-plugins/src/languages/rust/todo.md new file mode 100644 index 00000000..c3b62291 --- /dev/null +++ b/crates/code-ranker-plugins/src/languages/rust/todo.md @@ -0,0 +1,115 @@ +# Rust plugin — research notes & TODO + +Findings from exploring the Rust plugin's detection machinery and the optional +per-function analysis level. Reference for future work; not yet acted on. + +## 1. How `unsafe` detection works today + +The Rust structure builder parses each file with `syn::parse_file` and walks the +AST with `syn::visit::Visit` visitors. All of this lives under +`crates/code-ranker-plugins/src/languages/rust/`. + +`unsafe` is counted by `UnsafeCounter` (`module_graph/visitors.rs:58-103`), which +catches: + +- `unsafe { }` expression blocks (`visit_expr_unsafe`) +- `unsafe fn` — free functions, impl methods, trait methods +- `unsafe impl` and `unsafe trait` + +It is purely syntactic: `unsafe` produced inside a macro body is invisible +(macros are never expanded), and the count is not type-checked. + +The value flows through: + +1. **Counted** — `walk.rs:57,64` drives `UnsafeCounter` over non-test items. +2. **Stored on the node** — `walk.rs:81` `node.unsafe_count = Some(...)`; field + declared in `internal.rs:69`. +3. **Emitted as an attribute** — `collapse.rs:127-130` writes the `unsafe` key + (omitted when zero, like other metrics). +4. **Declared / described** — `config.toml:312-318` (`[node_attributes.unsafe]`: + label, description, `remediation`, `direction = "lower_better"`). +5. **Surfaced in the report** — `config.toml:367-371` adds the `unsafe` column and + a project-wide stat. + +## 2. Two extension paths for detecting more patterns + +**A. New numeric counter (mirror `unsafe`)** — for anything that must be counted +over the AST: + +- add a visitor (or a method on an existing one) in `module_graph/visitors.rs`; +- add the field to `internal.rs`; +- populate it in `module_graph/walk.rs`; +- emit it in `collapse.rs`; +- declare `[node_attributes.X]` (+ optionally `[report]`) in `config.toml`. + +**B. CEL rule over already-collected facts — no Rust change.** `FactsCollector` +(`module_graph/visitors.rs:109-172`) already gathers string facts per file: +`derives`, `macros`, `attrs`, `imports`, `types`, `traits`. A +`[rules.checks.<id>]` CEL predicate (`contains`, `matches`, `startsWith`, …) can +match on these directly in TOML — see `crates/code-ranker-graph/src/checks.rs`. + +## 3. Candidate anti-patterns → which path + +Most "anti-patterns" are *expression calls* nobody counts yet (only `unsafe` is +counted; only `derives`/`macros`/`attrs`/`imports`/`types`/`traits` are collected +as facts): + +| Pattern | Path | Hook | +| --- | --- | --- | +| `.unwrap()` / `.expect()` | A | `visit_expr_method_call` | +| `.clone()` | A | `visit_expr_method_call` | +| `panic!` / `todo!` / `unimplemented!` | **B** (already captured as macros) | — | +| `static mut` | A | `visit_item_static` (`mutability`) | +| `std::mem::transmute`, raw-pointer cast | A | `visit_expr_call` / `visit_expr_cast` | + +Anti-pattern references gathered while researching: + +- rust-unofficial/patterns — anti-patterns catalogue: <https://rust-unofficial.github.io/patterns/anti_patterns/index.html> +- The Rust Book, Unsafe Rust: <https://doc.rust-lang.org/book/ch20-01-unsafe-rust.html> + +Common ones worth detecting: excessive `.clone()`, `.unwrap()`/`.expect()`, +`#![deny(warnings)]`, stringly-typed / `Box<dyn Error>` errors, `Deref` +polymorphism, blocking I/O in async, `static mut`, raw-pointer misuse, +`transmute`, panicking across FFI, Rust types in `#[repr(C)]`. + +## 4. Per-function analysis — the optional `functions` level + +Function-level analysis exists and works; it is **opt-in**, off by default. + +**Enable** (config only — no CLI flag): + +```toml +[levels] +functions = true +``` + +Default is `functions = false` (`crates/code-ranker-cli/src/config/defaults.toml:68-69`). + +**What it emits** — alongside `graphs.files`, the JSON report gains +`graphs.functions` with one node per function/method/closure. Each node has: + +- `kind` from the language vocabulary (`function` / `method` / …), +- `name`, `parent` (the file id), and an id of the form `<file>#<name>@<line>`, +- all per-file metrics computed over the function body (`loc/sloc/cloc`, + `cyclomatic`, `cognitive`, Halstead, …). + +Verified by `functions_level_is_opt_in` (`crates/code-ranker-cli/tests/e2e.rs:1358`): +with `functions = true`, function `f` gets `cyclomatic = 2`, method `m` gets +`kind = "method"`. + +**Wiring:** + +- Level declared in `languages/rust/mod.rs:96-106` (`Level { name: "functions" }`). +- Nodes built in `function_units()` (`languages/rust/mod.rs:149-172`): the file is + re-read, inline tests stripped, `dialect::compute_functions()` splits it into + functions and computes metrics. +- Orchestrator gates the level on `cfg.levels.functions` + (`crates/code-ranker-cli/src/pipeline.rs:113-138, 284-303`). +- Implemented for all languages (Rust, Python, Go, C#, C, ECMAScript/TS, Markdown). + +**Limitations (deliberate):** + +- No edges — the `functions` level has an empty `edge_kinds`, so there is no call + graph (calls between functions are not tracked). +- No coupling metrics — `fan_in`/`fan_out`, HK index, and cycles are file-level only. +- Tests (`#[cfg(test)]` / `#[test]`) are excluded, as in the per-file metrics. From 148e92fe39147ce039c10577f637809070e246b7 Mon Sep 17 00:00:00 2001 From: Roman Fedorov <Roman.Fedorov@constructor.tech> Date: Tue, 23 Jun 2026 20:21:07 +0300 Subject: [PATCH 11/40] meta(prompt-eval): encode session corrections into the self-improve loop - meta-loop: a user correction is the strongest signal; fix THIS file before continuing, not after the sweep (the file not changing IS the bug) - algorithm: drive the loop to convergence; a lever edit is unfinished without its verifying re-run; don't pause for permission between iterations - tuning rule: diagnose from the transcript by hand, not from aggregate counts - launching: sub-agent cwd = code-ranker repo makes it 'cargo run' instead of the PATH binary, inflating cost columns --- contrib/prompting-self-improve.md | 39 +++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/contrib/prompting-self-improve.md b/contrib/prompting-self-improve.md index 690a06e7..0793df7c 100644 --- a/contrib/prompting-self-improve.md +++ b/contrib/prompting-self-improve.md @@ -93,6 +93,13 @@ Two nested loops. The **inner** loop improves the prompts; the **outer, meta** l ([below](#the-meta-loop--improving-this-playbook)) improves *this playbook* when the process itself gets in the way. Both are driven by the same measured signals. +**Drive the loop to its end — don't pause for permission between iterations, and don't +stop after a single pass.** A lever edit is only *half* a step: it is not done until its +rebuild (S1–S2) **and** its verifying re-run have scored the hypothesis against the +previous iteration. Stopping right after the edit leaves the loop unfinished and proves +nothing. Keep iterating (≤ 3 per model, then descend a tier) until the cheapest tier is +at the bar or the residual is recorded — that whole arc is one `improve(...)` call. + ``` for MODEL in models (most → least capable): # opus, then sonnet, then haiku… loop (≤ 3 times): @@ -221,6 +228,17 @@ one, never `--continue`/`--resume`. Keep `PROJECT` free of a code-ranker-specifi `CLAUDE.md`/memory so only `--doc 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 +`claude` below does this). If you instead drive it as a **sub-agent whose cwd is the +code-ranker source repo**, it sees a Cargo project there and tends to run the analyzer +via `cargo run --manifest-path <code-ranker>/Cargo.toml report …` — recompiling it and +dumping a build log into context — instead of the installed `code-ranker` on PATH. That +inflates the cost columns (`input_tokens`, `cache_read_tokens`, a couple of `commands`) +with work **no real user does**, so the cost axis is no longer comparable to a run +launched in `PROJECT`. Either launch in `PROJECT`, or tell the agent up front that +`code-ranker` is installed on PATH and the code-ranker source tree is not its concern — +and note in `metrics.csv` which basis the run used. + - **Claude Code** (Opus / Sonnet / Haiku), interactive — what the fix loop wants (multi-turn: run code-ranker, edit, run tests): @@ -370,6 +388,16 @@ column** (the meta-loop), not to fudge the score. ## Tuning rule +**Diagnose from the transcript by hand, not from the aggregates.** Before scoring and +before choosing a lever, read the run's `chat.jsonl` turn by turn. The collector's +columns (`tool_calls`, `discovery_retries`, `output_tokens`, `first_edit_turn`) tell you +*how much* was spent and *that* the model groped; only the turn-by-turn record shows +*where* and *why* it diverged — which is what actually picks the lever. A lever chosen +from counts alone over-fits the number, not the failure class. (Counts also mislead: a +high `discovery_retries` can be benign compile iterations, and inflated tokens can be a +measurement artifact — see the cwd caution under "Launching a clean-context agent" — both +only visible by reading the log.) + A prompt change is justified when a cheaper model misses on **any** of the three objectives in a way the prompt *could* have prevented: @@ -391,9 +419,16 @@ row stays in `metrics.csv`) so it's a decision on record, not a silent failure. ## The meta-loop — improving this playbook -The prompts are levers; so is this file. After a sweep, ask whether the *process* -helped or fought you, and edit the playbook when it fought: +The prompts are levers; so is this file. After a sweep — and the **moment the user has +to correct how you ran the loop** — ask whether the *process* helped or fought you, and +edit the playbook when it fought: +- a **correction from the user** — they told you the loop skipped a step, stopped early, + read the wrong evidence, or measured the wrong thing → this is the **strongest** + meta-signal. If you had to be told, the playbook was unclear. Encode the correction + into THIS file **before continuing** the sweep, not after it. The file *not* changing + after a correction is itself the bug — "self-improving" means the next run can't repeat + the mistake you were just corrected for. - a **run that taught nothing** (you couldn't tell *why* the fix scored as it did) → fix what a run captures, or add a metric column that would have shown it; - a **signal that didn't discriminate** quality, cost, or clarity → sharpen the From 5335b5c10b10c5022f219f6e286cb8115a1f12c7 Mon Sep 17 00:00:00 2001 From: Roman Fedorov <Roman.Fedorov@constructor.tech> Date: Tue, 23 Jun 2026 20:22:05 +0300 Subject: [PATCH 12/40] prompt(scaffolding): front-load --doc {id} as first step before reading source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit doc_note read as a soft mid-prompt aside; cheaper tiers explored the code first and re-derived the mechanism the doc already states (verbatim, with a worked example for this very project). Reframe as a directive first step that promises the payoff (cause + smallest fix + worked example). Hypothesis (prompt-eval): cuts pre-edit exploration on the next sonnet-ADP sweep — first_edit_turn / commands / tool_calls / output_tokens drop vs the f6ddc88 sonnet-ADP-1 baseline. Verify-or-revert. --- crates/code-ranker-graph/metrics/prompt.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/code-ranker-graph/metrics/prompt.md b/crates/code-ranker-graph/metrics/prompt.md index 8257fc93..dbf0e305 100644 --- a/crates/code-ranker-graph/metrics/prompt.md +++ b/crates/code-ranker-graph/metrics/prompt.md @@ -15,7 +15,7 @@ I want to apply this to some modules in my system. ## doc_note -To understand the principle in detail, run `code-ranker report --doc {id}` — it prints the full principle to your terminal. Read it before proposing any changes. +**First, before reading the source**, run `code-ranker report --doc {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 From c0d69794b01869dfd06f62221db2f8f52c7c0b28 Mon Sep 17 00:00:00 2001 From: Roman Fedorov <Roman.Fedorov@constructor.tech> Date: Tue, 23 Jun 2026 21:18:13 +0300 Subject: [PATCH 13/40] =?UTF-8?q?meta(prompt-eval):=20lever=20hygiene=20?= =?UTF-8?q?=E2=80=94=20keep=20language-specific=20content=20out=20of=20bas?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A lever for a cheaper tier's missing remedy must not leak language-specifics (Rust pub(in ...)) into languages/base/AI.md or the neutral defaults.toml; the base lever stays generic and points at the per-language doc. --- contrib/prompting-self-improve.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/contrib/prompting-self-improve.md b/contrib/prompting-self-improve.md index 0793df7c..cbc2b8db 100644 --- a/contrib/prompting-self-improve.md +++ b/contrib/prompting-self-improve.md @@ -72,6 +72,15 @@ of these and rebuild (see Setup) — all are baked into the binary: Change the **smallest** lever that fixes the observed failure. +**Respect the base / per-language boundary.** Language-specific content (Rust +`pub(in …)`, a Python import idiom, …) belongs ONLY in `languages/<lang>/` (its +`<FOCUS>.md` doc) or the per-language `config.toml` prompt override — **never** in the +language-neutral `languages/base/AI.md` or the neutral `defaults.toml` prompt. When a +cheaper tier fails for want of a language-specific remedy, the base lever stays generic +("read `--doc <principle>` — it has the cause and smallest fix for *your* language") and +the specifics live in the per-language doc it points at. Putting a Rust example in +`base/` leaks into every other language's output. + ## Setup (once per prompt version) - **S1 — fresh build on PATH.** Release-build and install locally so the From 93df81264820d1af6f651a3d263f377039affda2 Mon Sep 17 00:00:00 2001 From: Roman Fedorov <Roman.Fedorov@constructor.tech> Date: Tue, 23 Jun 2026 21:18:13 +0300 Subject: [PATCH 14/40] prompt(AI): make reading --doc <principle> an explicit, non-optional fix-loop step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Haiku skipped the deep doc (read_doc_focus=0) despite used_generated_prompt=1 and a front-loaded doc_note — the base AI.md fix loop had no doc-read step, so the agent treated it as optional and reached for a heavy crate-level extraction (ADP 16->9 only, new_cycle, -15 tests). Add it as step 3 (not optional), language-neutral; the per-language cause/remedy lives in languages/<lang>/<ID>.md. Hypothesis (prompt-eval): haiku-ADP-2 read_doc_focus 0->1, fix shape -> in-place visibility narrowing, focus_delta -7->-11, new_cycles 1->0, tests preserved. --- languages/base/AI.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/languages/base/AI.md b/languages/base/AI.md index 6fed2127..dd163987 100644 --- a/languages/base/AI.md +++ b/languages/base/AI.md @@ -68,11 +68,18 @@ inspect the worst tier with `--severity warning`. ## The fix loop ```sh -code-ranker check . # the gate verdict -code-ranker report . --output.scorecard --focus cycle --top 1 # focus one metric/principle -code-ranker report . --output.prompt.path=stdout --top 1 # fix-prompt, worst module +code-ranker check . # 1. the gate verdict +code-ranker report . --output.scorecard --focus cycle --top 1 # 2. focus one metric/principle, worst-first +code-ranker report --doc <principle> # 3. READ the deep doc — before you touch code +code-ranker report . --output.prompt.path=stdout --top 1 # 4. fix-prompt for the worst module ``` +**Step 3 is not optional — read the `--doc <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 +one, or drop tests. Read it first; then fix. + `--focus` takes any catalog id below (a principle like `ADP`, or a metric like `hk` / `cycle`): focusing on a metric frames the output by that metric; on a principle, by that design principle. From a54ee6f84b765c21a72311d1df719f2eddc764a5 Mon Sep 17 00:00:00 2001 From: Roman Fedorov <Roman.Fedorov@constructor.tech> Date: Tue, 23 Jun 2026 21:29:16 +0300 Subject: [PATCH 15/40] doc(rust ADP): make visibility-narrowing an explicit step-1 remedy, before extract/move haiku-ADP-2 read --doc ADP and produced a correct fix, but over-extracted (moved transformers.rs to a leaf, 11 files) where in-place pub(in...)->pub(super) narrowing (opus/sonnet, +5/-5) was the minimal fix. The visibility remedy was present but sat before prominent extract-to-leaf shapes; cheaper tiers picked the heavier pattern. Reframe as an ordered procedure: find back-edges -> narrow visibility paths first -> extract/split only for genuine use back-edges. Hypothesis (prompt-eval): haiku-ADP-3 keeps quality (cycle gone, tests intact) but shrinks the fix to in-place narrowing -> loc/files near opus/sonnet, quality 4->5. --- languages/rust/ADP.md | 39 +++++++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/languages/rust/ADP.md b/languages/rust/ADP.md index e1c3bc72..122ec4e1 100644 --- a/languages/rust/ADP.md +++ b/languages/rust/ADP.md @@ -67,21 +67,32 @@ pub(in crate::services::user_service) fn update_user_status(/* … */) { /* … */ } ``` -When a cycle's back-edges to the module root are **only** -such visibility paths (not `use` statements), the smallest -fix is to **tighten the visibility** to the narrowest scope -the item's real consumers need — `pub(super)` if only the -parent uses it, `pub(crate)` if a sibling subtree does. The -edge to the root disappears, the cycle breaks, and the item -is genuinely less exposed (least privilege) — no module moved. - -**Only do this when every consumer already lives in the -narrower scope.** Check the call sites first: if the item is -truly used across the whole subtree, narrowing won't compile -— that is a *real* dependency, so extract the shared item -into a leaf module or invert the dependency instead. +**This is the first remedy to try for a Rust module cycle — +before any extract / split / move shape in the sections +below.** Work it in order: + +1. **Find the back-edges** — the cycle edges that point *up* + to an ancestor module. For each, check whether it is a + real `use` or only a `pub(in <path>)` visibility path. +2. **If a back-edge is a visibility path, narrow it** to the + smallest scope its real callers need — `pub(super)` if + only the parent uses the item, `pub(crate)` if a sibling + subtree does. The edge to the root disappears, the cycle + breaks, the item is less exposed (least privilege), and + **no module moves and no test changes** — usually a + one-line edit per item. +3. **Only a genuine `use` back-edge** needs the structural + shapes below (extract a shared leaf, invert, split). + +Check the call sites before narrowing: if an item is truly +used across the whole subtree, narrowing won't compile — that +is a *real* dependency, so extract or invert instead. Tightening a visibility you have not verified is silencing -the metric, not fixing the structure. +the metric, not fixing the structure. But the reverse is the +more common cheaper-tier mistake: **do not extract or move a +module when a one-line visibility change is the actual fix.** +The shapes below are for `use` cycles; reach for them only +after step 1 shows the back-edges are real imports. <!-- doc:base "Module-level cycles" --> <!-- doc:base "Common cycle shapes" --> From 24f0243d0513fb7d1ce12ebec31ca69a887ff959 Mon Sep 17 00:00:00 2001 From: Roman Fedorov <Roman.Fedorov@constructor.tech> Date: Tue, 23 Jun 2026 21:39:44 +0300 Subject: [PATCH 16/40] meta(prompt-eval): note new_cycles false-positive on shrunk cycles A cycle whose membership only shrank (subset of a pre-existing cycle the fix partially cleared) registers as new_cycles; it nearly mis-scored haiku-ADP-3 (dc06762) down despite a clean minimal fix. Diff cycle node-sets before scoring. --- contrib/prompting-self-improve.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contrib/prompting-self-improve.md b/contrib/prompting-self-improve.md index cbc2b8db..b9ad31a1 100644 --- a/contrib/prompting-self-improve.md +++ b/contrib/prompting-self-improve.md @@ -308,7 +308,7 @@ Columns, grouped by objective (most are extractable from the run's artifacts; th | `focus_before` / `focus_after` | quality | before/after `.json` scorecard | FOCUS violation count (e.g. ADP warnings) | | `focus_delta` | quality | `after − before` | ↓ (negative) = fewer violations | | `worst_before` / `worst_after` | quality | before/after `.json` | size of the worst instance (e.g. SCC node count) | -| `new_cycles` | quality | after vs before `.json` | ↓ cycles present in `after` but **not** `before` — regression guard (a fix that breaks one cycle and creates another scores 0 here) | +| `new_cycles` | quality | after vs before `.json` | ↓ cycles present in `after` but **not** `before` — regression guard (a fix that breaks one cycle and creates another scores 0 here). ⚠ **False positive:** a cycle whose membership only *shrank* (the survivor is a subset of a pre-existing cycle the fix partially cleared) registers here as "new". Diff the cycle node-sets before scoring a fix down — a subset/remnant is a *shrink*, not a new cycle. (Collector meta-gap: should classify subset-of-before as shrink.) | | `collateral_delta` | quality | full scorecard at main vs branch | Δ in **non-FOCUS** principle violations (run `report --output.scorecard --top 0` at each git state, sum all rows except FOCUS). ↓ = a fix that also cleared other principles; ↑ = collateral damage | | `quality_1_5` | quality | transcript + diff | ↑ real fix (extract/invert/split) vs metric-hack | | `tool_calls` | cost | transcript | ↓ total tool invocations (Read/Edit/Bash/Grep/…) | From be583e82cea553812923cd2ba1d3b54908211672 Mon Sep 17 00:00:00 2001 From: Roman Fedorov <Roman.Fedorov@constructor.tech> Date: Tue, 23 Jun 2026 21:54:50 +0300 Subject: [PATCH 17/40] meta(prompt-eval): name PROJECT branch identically to the run build dir Branch = <ts>_<CR_SHA> (e.g. 20260623T1849Z_dc06762), matching the evidence folder name 1:1; UTC <ts> makes each run unique (no bump <n>). One run per build dir; pass --branch to the collector (no longer <run>-<CR_SHA>). --- contrib/prompting-self-improve.md | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/contrib/prompting-self-improve.md b/contrib/prompting-self-improve.md index b9ad31a1..f896994d 100644 --- a/contrib/prompting-self-improve.md +++ b/contrib/prompting-self-improve.md @@ -173,9 +173,11 @@ nothing eval-related is left in `PROJECT`. change. (This is also why the orchestrator must stage explicit paths, never `git add -A`, when committing the fix.) 8. **Save the transcript** to `$RUN/chat.md` (see "Saving the chat"), commit the code - change to branch `<MODEL>-<FOCUS>-<N>-<CR_SHA>` in `PROJECT` (the `-<CR_SHA>` suffix - keeps it unique across builds — see [Artifacts](#artifacts-layout--naming)), return - to `main`. + change to a branch named **identically to this run's build dir** — `<TS>_<CR_SHA>` + (e.g. `20260623T1849Z_dc06762`) — in `PROJECT`, then return to `main`. Branch name == + evidence-folder name, so code ↔ evidence line up by one identical string, and the UTC + `<TS>` makes every run's branch unique (no "bump `<n>`"). Pass that exact branch name + to the collector via `--branch`. 9. **Measure.** Append one row to `prompt-eval/metrics.csv` with the collector — don't hand-compute it (see [Metrics](#metrics-metricscsv) → Collecting a row): @@ -215,16 +217,15 @@ Layout (one build → one `<timestamp>_<CR_SHA>` folder → one subfolder per ru └─ haiku-cycle-2/ dir same shape ``` -- folder/run id = `<model>-<focus>-<n>`; the PROJECT **branch** for that run is - `<model>-<focus>-<n>-<CR_SHA>`. The run folder already sits under a - `<ts>_<CR_SHA>` build dir, so the id alone is unique *there*; but PROJECT branches - are flat and live across **every** build, so the same run id recurs and would - collide. The `-<CR_SHA>` suffix makes the branch unique **per prompt version** and - ties it back to this run's build dir — evidence ↔ code still line up, now by - `(run id, CR_SHA)`. (If you re-run the *same* commit in a fresh build dir, that - branch already exists — bump `<n>` until free; `<n>` is the iteration counter - anyway.) The collector defaults `--branch` to `<run>-<CR_SHA>`, so loc/files come - from the right branch without passing it. +- folder/run id = `<model>-<focus>-<n>`; the PROJECT **branch** for that run is named + **identically to the run's build dir** — `<ts>_<CR_SHA>` (e.g. + `20260623T1849Z_dc06762`). Give each run its **own** `<ts>_<CR_SHA>` build dir (one run + subfolder per build dir) so that folder name is a unique per-run id, and the branch + reuses it verbatim. PROJECT branches are flat and live across every build, but the UTC + `<ts>` makes each unique — no more "bump `<n>` until free". Code ↔ evidence line up by + the shared `<ts>_<CR_SHA>` string. The branch is no longer `<run>-<CR_SHA>`, so **pass + it to the collector via `--branch`**. (If a build dir ever holds several runs, suffix + the branch with the run-id: `<ts>_<CR_SHA>_<run-id>`.) - the code-ranker version/commit is also embedded *inside* each report (from S2), so a file stays self-describing even if moved out of its folder. - HTML reports are large (self-contained, WASM inlined); JSON snapshots scale with From 5bb4812a6459e394a1663ae5f2b44010f30185ec Mon Sep 17 00:00:00 2001 From: Roman Fedorov <Roman.Fedorov@constructor.tech> Date: Tue, 23 Jun 2026 21:58:38 +0300 Subject: [PATCH 18/40] meta(prompt-eval): note PROMPTEVAL- commit prefix for builddir-named branches A <ts>_<CR_SHA> branch carries no ticket, so a project prepare-commit-msg hook rejects the commit; --no-verify does not skip prepare-commit-msg. Prefix eval commits with a pseudo-ticket (PROMPTEVAL-N:). --- contrib/prompting-self-improve.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/contrib/prompting-self-improve.md b/contrib/prompting-self-improve.md index f896994d..5b0d7690 100644 --- a/contrib/prompting-self-improve.md +++ b/contrib/prompting-self-improve.md @@ -177,7 +177,11 @@ nothing eval-related is left in `PROJECT`. (e.g. `20260623T1849Z_dc06762`) — in `PROJECT`, then return to `main`. Branch name == evidence-folder name, so code ↔ evidence line up by one identical string, and the UTC `<TS>` makes every run's branch unique (no "bump `<n>`"). Pass that exact branch name - to the collector via `--branch`. + to the collector via `--branch`. **Commit-msg gotcha:** if `PROJECT` has a + `prepare-commit-msg` hook that derives a ticket from the branch name, the `<TS>_<CR_SHA>` + branch carries none so the commit is rejected — and `--no-verify` does **not** skip + `prepare-commit-msg` (only pre-commit / commit-msg). Prefix the eval commit message with + a pseudo-ticket, e.g. `PROMPTEVAL-1: <subject>`. 9. **Measure.** Append one row to `prompt-eval/metrics.csv` with the collector — don't hand-compute it (see [Metrics](#metrics-metricscsv) → Collecting a row): From 1cc2a0ddad65095648d982ca3e3557fc59e8bf5d Mon Sep 17 00:00:00 2001 From: Roman Fedorov <Roman.Fedorov@constructor.tech> Date: Tue, 23 Jun 2026 22:24:05 +0300 Subject: [PATCH 19/40] chore(studio): initialize Constructor Studio (cfs init + sdlc kit) Run 'cfs init' (engine v1.5.9, kit sdlc v1.2.1): adds root AGENTS.md/CLAUDE.md navigation blocks and gitignore entries for cf-* agent integrations. The .cf-studio/ dir stays gitignored (pre-existing rule). --- .gitignore | 6 ++++++ AGENTS.md | 7 +++++++ CLAUDE.md | 7 +++++++ 3 files changed, 20 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/.gitignore b/.gitignore index 1693a71a..e1b84b0e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,9 @@ __pycache__/ *.egg-info/ dist/ build/ + +# BEGIN Constructor Studio +# Generated Constructor Studio runtime and agent integration files. +# Files matched here are owned by Constructor Studio and may be overwritten. +.cf-studio/ +# END Constructor Studio diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..c03e8cf1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,7 @@ +<!-- @cf:root-agents --> +```toml +cf-studio-path = ".cf-studio" +``` + +ALWAYS resolve and enforce prerequisites of skills/workflows/commands BEFORE applying user intent. +<!-- /@cf:root-agents --> diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..c03e8cf1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,7 @@ +<!-- @cf:root-agents --> +```toml +cf-studio-path = ".cf-studio" +``` + +ALWAYS resolve and enforce prerequisites of skills/workflows/commands BEFORE applying user intent. +<!-- /@cf:root-agents --> From 5d7897600889b5ccd2e0e2e887f0b79b14d638d7 Mon Sep 17 00:00:00 2001 From: Roman Fedorov <Roman.Fedorov@constructor.tech> Date: Tue, 23 Jun 2026 22:25:00 +0300 Subject: [PATCH 20/40] docs(toc): sync tables of contents via cfs toc cfs validate-toc flagged 15 headings (h3 subsections) missing from their TOCs. Regenerated with 'cfs toc'; additive only (+15 entries), now all TOCs validate. --- docs/code-ranker-cli/DESIGN.md | 4 ++++ docs/code-ranker-viewer/DESIGN.md | 10 ++++++++++ docs/code-ranker-viewer/PRD.md | 1 + 3 files changed, 15 insertions(+) diff --git a/docs/code-ranker-cli/DESIGN.md b/docs/code-ranker-cli/DESIGN.md index bdada965..46c514b2 100644 --- a/docs/code-ranker-cli/DESIGN.md +++ b/docs/code-ranker-cli/DESIGN.md @@ -19,6 +19,10 @@ viewer assets see [`code-ranker-viewer/DESIGN.md`](../code-ranker-viewer/DESIGN. - [Report Generator](#report-generator) - [Check / Regression Gate](#check--regression-gate) - [3. CLI Reference and Examples](#3-cli-reference-and-examples) + - [Snapshots — `code-ranker report --output.json`](#snapshots--code-ranker-report---outputjson) + - [Visualization — `code-ranker report`](#visualization--code-ranker-report) + - [Compare against a baseline — `--baseline`](#compare-against-a-baseline----baseline) + - [Full end-to-end workflow](#full-end-to-end-workflow) <!-- /toc --> diff --git a/docs/code-ranker-viewer/DESIGN.md b/docs/code-ranker-viewer/DESIGN.md index e824a3b9..295c3baa 100644 --- a/docs/code-ranker-viewer/DESIGN.md +++ b/docs/code-ranker-viewer/DESIGN.md @@ -13,6 +13,16 @@ plugin/extraction crates and the plugin system see the main - [HTML assets (`crates/code-ranker-viewer/src/assets/`)](#html-assets-cratescode-ranker-viewersrcassets) - [Asset layers](#asset-layers) + - [Vendor](#vendor) + - [Data (pure, no DOM)](#data-pure-no-dom) + - [Graph layout & render](#graph-layout--render) + - [Map interactions](#map-interactions) + - [Node modal / popup](#node-modal--popup) + - [Shared](#shared) + - [Tables & summary](#tables--summary) + - [Export](#export) + - [App shell](#app-shell) + - [Shell template & styles](#shell-template--styles) - [Navigation: tier, focus & reveal depth](#navigation-tier-focus--reveal-depth) - [Affected status](#affected-status) - [Cycle detection](#cycle-detection) diff --git a/docs/code-ranker-viewer/PRD.md b/docs/code-ranker-viewer/PRD.md index 47e603a6..9a864f06 100644 --- a/docs/code-ranker-viewer/PRD.md +++ b/docs/code-ranker-viewer/PRD.md @@ -13,6 +13,7 @@ plugin/extraction layer, graph model and JSON schema, see the main - [1. Visualization Reports — Step 2](#1-visualization-reports--step-2) - [HTML Report Generation](#html-report-generation) - [Node Sorting by Weight](#node-sorting-by-weight) + - [Map navigation — semantic zoom & cycle visibility](#map-navigation--semantic-zoom--cycle-visibility) - [AI Prompt Generator (P2)](#ai-prompt-generator-p2) - [Principles-Based Prompt Generation (P3)](#principles-based-prompt-generation-p3) - [2. Baseline Comparison — diff viewer (Step 4)](#2-baseline-comparison--diff-viewer-step-4) From 31e80da75ef1e64b9ee689a5d694ca405f0ebae0 Mon Sep 17 00:00:00 2001 From: Roman Fedorov <Roman.Fedorov@constructor.tech> Date: Tue, 23 Jun 2026 22:31:31 +0300 Subject: [PATCH 21/40] fix(prompt-eval): make the metrics collector focus-aware (non-cycle metrics) from_snapshots was hardcoded to the cycle metric, so an HK/sloc/cognitive run reported ADP cycle counts in focus_*/worst_*/new_cycles. Now: cycle FOCUS keeps the SCC logic; a metric FOCUS reads the metric off the module nodes -> worst_* = worst module's value (the --top 1 target), focus_* = project-wide sum (flat total beside a dropped worst = relocated, not dissolved), new_cycles blank. Direction from node_attributes schema (higher_better -> worst is min). read_doc_focus alias set is now focus-aware too. Playbook column docs updated to match. Exposed by the first HK run (opus max): worst HK 390825->140697, total 603969->353313. --- contrib/prompt-eval-metrics.py | 77 ++++++++++++++++++++++++------- contrib/prompting-self-improve.md | 6 +-- 2 files changed, 64 insertions(+), 19 deletions(-) diff --git a/contrib/prompt-eval-metrics.py b/contrib/prompt-eval-metrics.py index 00dd3527..a4643765 100755 --- a/contrib/prompt-eval-metrics.py +++ b/contrib/prompt-eval-metrics.py @@ -11,8 +11,13 @@ api_duration_s, doc reads + rereads, first_edit_turn, used_generated_prompt, focus_framing, discovery_retries, (heuristic) tests_pass, planned_before_edit - - before/after.json -> cycle counts -> focus_before/after, worst_before/after, - new_cycles (ADP / cycle focus; blank for other metrics) + - before/after.json -> focus_before/after, worst_before/after, new_cycles. + For a cycle FOCUS (ADP/cycle): cycle counts + worst SCC size + + new-cycle count. For a metric FOCUS (hk/sloc/cognitive/…): the + metric read off the module nodes — worst_* = the worst module's + value (the --top 1 target), focus_* = the project-wide sum (flat + total beside a dropped worst = coupling relocated, not dissolved), + new_cycles blank. And, when --project-path is given, the PROJECT branch git diff -> files_changed, loc_added, loc_removed (branch defaults to <run>-<cr_sha>, unique per prompt version). @@ -147,8 +152,9 @@ def parse(x): if tail: docs.append(tail[0]) m["read_doc_ai"] = 1 if any(d == "AI" for d in docs) else 0 - focus_doc_aliases = {focus, "ADP", "cycle"} - m["read_doc_focus"] = 1 if any(d in focus_doc_aliases for d in docs) else 0 + fl = (focus or "").lower() + aliases = {"adp", "cycle", "cycles"} if fl in CYCLE_FOCI else {fl} + m["read_doc_focus"] = 1 if any(d.lower() in aliases for d in docs) else 0 m["doc_reread"] = len(docs) - len(set(docs)) # adherence @@ -205,21 +211,60 @@ def walk(o): return found -def from_snapshots(run_dir): +CYCLE_FOCI = {"adp", "cycle", "cycles"} + + +def node_metric(path, key): + """(values, direction) for one metric across internal (non-external) module nodes. + + `key` is a node attribute (e.g. `hk`, `sloc`, `cognitive`); `direction` comes from + the snapshot's node_attributes schema (`lower_better` / `higher_better` / None).""" + with open(path) as fh: + d = json.load(fh) + files = (d.get("graphs") or {}).get("files") or {} + vals = [n[key] for n in files.get("nodes") or [] + if not n.get("external") and isinstance(n.get(key), (int, float))] + direction = ((files.get("node_attributes") or {}).get(key) or {}).get("direction") + return vals, direction + + +def from_snapshots(run_dir, focus): bj, aj = os.path.join(run_dir, "before.json"), os.path.join(run_dir, "after.json") if not (os.path.exists(bj) and os.path.exists(aj)): return {} - before, after = cycles(bj), cycles(aj) - sig = lambda cs: sorted((k, n) for k, n in cs) - bset = list(sig(before)) - new = [c for c in sig(after) if not (c in bset and bset.remove(c) is None)] + + if (focus or "").lower() in CYCLE_FOCI: + before, after = cycles(bj), cycles(aj) + sig = lambda cs: sorted((k, n) for k, n in cs) + bset = list(sig(before)) + new = [c for c in sig(after) if not (c in bset and bset.remove(c) is None)] + return { + "focus_before": sum(n for _, n in before), + "focus_after": sum(n for _, n in after), + "focus_delta": sum(n for _, n in after) - sum(n for _, n in before), + "worst_before": max((n for _, n in before), default=0), + "worst_after": max((n for _, n in after), default=0), + "new_cycles": len(new), + } + + # non-cycle metric focus (hk, sloc, cognitive, cyclomatic, fan_in, …): read the + # focused metric off the module nodes. worst_* = the worst module's value (the + # `--top 1` target); focus_* = the project-wide sum (a flat total beside a dropped + # worst = coupling *relocated*, not dissolved). new_cycles is N/A here. + key = (focus or "").lower().replace("-", "_") + bvals, direction = node_metric(bj, key) + avals, _ = node_metric(aj, key) + if not bvals and not avals: + return {} # unknown/absent metric — leave the columns blank rather than wrong + worst = min if direction == "higher_better" else max + rnd = (lambda x: round(x, 2)) if any(isinstance(v, float) for v in bvals + avals) else int return { - "focus_before": sum(n for _, n in before), - "focus_after": sum(n for _, n in after), - "focus_delta": sum(n for _, n in after) - sum(n for _, n in before), - "worst_before": max((n for _, n in before), default=0), - "worst_after": max((n for _, n in after), default=0), - "new_cycles": len(new), + "focus_before": rnd(sum(bvals)), + "focus_after": rnd(sum(avals)), + "focus_delta": rnd(sum(avals) - sum(bvals)), + "worst_before": rnd(worst(bvals)) if bvals else 0, + "worst_after": rnd(worst(avals)) if avals else 0, + "new_cycles": "", } @@ -278,7 +323,7 @@ def main(): if not os.path.exists(chat): sys.exit(f"no chat.jsonl in {run_dir}") row.update(from_transcript(chat, focus)) - row.update(from_snapshots(run_dir)) + row.update(from_snapshots(run_dir, focus)) if args.project_path: # PROJECT branches are flat and live across every build, so the run id alone diff --git a/contrib/prompting-self-improve.md b/contrib/prompting-self-improve.md index 5b0d7690..c5c8bde5 100644 --- a/contrib/prompting-self-improve.md +++ b/contrib/prompting-self-improve.md @@ -310,9 +310,9 @@ Columns, grouped by objective (most are extractable from the run's artifacts; th |---|---|---|---| | `ts`,`cr_sha`,`project`,`focus`,`model`,`iter`,`run` | id | run.md | identity — `cr_sha` is the prompt version | | `tests_pass` | quality | project tests | 1/0 — tests green, behaviour preserved | -| `focus_before` / `focus_after` | quality | before/after `.json` scorecard | FOCUS violation count (e.g. ADP warnings) | -| `focus_delta` | quality | `after − before` | ↓ (negative) = fewer violations | -| `worst_before` / `worst_after` | quality | before/after `.json` | size of the worst instance (e.g. SCC node count) | +| `focus_before` / `focus_after` | quality | before/after `.json` | **Focus-aware.** Cycle FOCUS (`ADP`/`cycle`): total cycle-warning count. Metric FOCUS (`HK`, `sloc`, `cognitive`, …): the **project-wide sum** of that metric across module nodes — a flat total beside a dropped `worst_*` means the fix **relocated** the cost rather than dissolving it. | +| `focus_delta` | quality | `after − before` | ↓ (negative) = better (fewer cycle warnings, or lower total metric) | +| `worst_before` / `worst_after` | quality | before/after `.json` | worst instance: cycle FOCUS → largest SCC node count; metric FOCUS → the **worst module's metric value** (the `--top 1` target, e.g. HK 390825 → 140697). Direction from the snapshot's `node_attributes` schema (`higher_better` → worst is the min). | | `new_cycles` | quality | after vs before `.json` | ↓ cycles present in `after` but **not** `before` — regression guard (a fix that breaks one cycle and creates another scores 0 here). ⚠ **False positive:** a cycle whose membership only *shrank* (the survivor is a subset of a pre-existing cycle the fix partially cleared) registers here as "new". Diff the cycle node-sets before scoring a fix down — a subset/remnant is a *shrink*, not a new cycle. (Collector meta-gap: should classify subset-of-before as shrink.) | | `collateral_delta` | quality | full scorecard at main vs branch | Δ in **non-FOCUS** principle violations (run `report --output.scorecard --top 0` at each git state, sum all rows except FOCUS). ↓ = a fix that also cleared other principles; ↑ = collateral damage | | `quality_1_5` | quality | transcript + diff | ↑ real fix (extract/invert/split) vs metric-hack | From 95fa34a1d9bb625431bf7635c624249221312c57 Mon Sep 17 00:00:00 2001 From: Roman Fedorov <Roman.Fedorov@constructor.tech> Date: Tue, 23 Jun 2026 22:54:47 +0300 Subject: [PATCH 22/40] =?UTF-8?q?doc(rust=20HK):=20order=20the=20remedies?= =?UTF-8?q?=20=E2=80=94=20narrow=20artificial=20fan-in,=20then=20split=20B?= =?UTF-8?q?Y=20ROLE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading 3 HK runs' real diffs: opus/sonnet narrowed pub(in) visibility (root cause, 1 line); haiku followed the doc's only stated remedy ('prefer the split') and did a mechanical facade split (trait away from impl) that shaved sloc and even widened field visibility -- HK number down, coupling not separated. Fix: (1) note pub(in <ancestor>) inflates fan_in artificially (consistent with ADP.md); (2) state the highest-value HK fix is splitting a multi-ROLE hub by responsibility (cuts real fan_in AND fan_out), NOT shaving sloc by moving a decl from its impl. Ordered: narrow artificial fan-in -> split by role -> never mechanical-split a cohesive role. Hypothesis (prompt-eval): haiku-HK-2 switches from facade split to visibility narrowing / role split -> q4->q5, footprint collapses. Verify-or-revert. --- languages/rust/HK.md | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/languages/rust/HK.md b/languages/rust/HK.md index ae714bd5..94ce6362 100644 --- a/languages/rust/HK.md +++ b/languages/rust/HK.md @@ -4,14 +4,45 @@ ## In Rust Fan-in and fan-out are counted over real code dependencies (`use` paths, -qualified paths, derives) — the flow edges, not structural `mod`/`pub use` -relationships. A Rust module scores high HK when it is both widely imported -and imports widely: +qualified paths, derives) — the flow edges, not structural `mod` / `pub use` +re-export relationships. **One thing inflates `fan_in` artificially:** a +`pub(in <ancestor>)` restricted-visibility path is recorded as a fan-in edge up +to that ancestor even when nothing there `use`s the item (the same modelling as +[ADP](ADP.md)). A Rust module scores high HK when it is both widely imported and +imports widely: - A `lib.rs` or `mod.rs` facade that re-exports and also orchestrates. - A `types.rs` / `model.rs` that every layer imports *and* that itself pulls in serialization, validation, and persistence concerns. - A `utils.rs` junk drawer that accumulates helpers used everywhere. + +### Remedies, in order + +**1. Narrow artificial fan-in first.** Before anything structural, check whether +the hub's in-edges are real `use` imports or just over-broad +`pub(in <ancestor>)` visibility. If artificial, narrow the visibility +(`pub(super)` if only the parent uses the item, `pub(crate)` if a sibling subtree +does): the edge dissolves, `fan_in` drops, and HK falls — a one-line change, no +split. (`HK = sloc × (fan_in × fan_out)²`, so dropping `fan_in` from 5 to 2 alone +is a ~6× cut.) + +**2. The highest-value fix: split a multi-role hub by responsibility.** When the +in-edges are *real*, the most valuable thing you can do is separate a component +that has accreted **2–3 distinct roles** into **one module per role**. A file +that does, say, *field mapping* **and** *platform-API orchestration* **and** +*status handling* is three components wearing one name; giving each its own +module is what genuinely cuts HK, because each role then couples only to its own +dependencies — **both `fan_in` and `fan_out` drop for real**, and each piece +becomes independently testable and changeable. Find the seams (distinct +responsibilities, distinct dependency sets) and cut along them. + +**3. Do not shave `sloc` by mechanical splitting.** Moving a type declaration +away from its `impl`, or hoisting a trait into a sibling file, splits **one +cohesive role** across two files. It lowers the HK *number* (less `sloc`) without +separating any responsibility — and can even *raise* coupling by widening +visibility to make the move compile. That is metric-gaming, not decoupling; see +"When a hub is legitimate" below. The test: if your split does not leave each +new module owning a **distinct role**, it is not a real HK fix. <!-- doc:base "Reducing it" --> ## When a hub is legitimate (accept, don't game) From a6f0f6f13134ece3081c4e543cdbc8dbabd2e025 Mon Sep 17 00:00:00 2001 From: Roman Fedorov <Roman.Fedorov@constructor.tech> Date: Tue, 23 Jun 2026 23:24:48 +0300 Subject: [PATCH 23/40] fix(cli/doc): route metric docs through doc_overrides, not always base/ doc_rel_path hardcoded metric docs to base/<doc>.md, so a per-language metric doc (languages/rust/HK.md) was embedded but NEVER served: --doc HK always returned the neutral base doc, while --doc ADP (a principle) correctly composed rust/ADP.md. Metric docs now reuse the override the plugin already applied to its principle docs (override_lang, inferred from principle doc_urls): serve <lang>/<doc>.md when present, else base/. Verified: --doc HK now composes rust/HK.md; --doc ADP unchanged; base fallback clean; 145 unit tests pass. --- crates/code-ranker-cli/src/templates.rs | 28 ++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/crates/code-ranker-cli/src/templates.rs b/crates/code-ranker-cli/src/templates.rs index 467d853a..b42f7da7 100644 --- a/crates/code-ranker-cli/src/templates.rs +++ b/crates/code-ranker-cli/src/templates.rs @@ -45,15 +45,24 @@ fn doc_rel_path(snap: &Snapshot, id: &str) -> Option<String> { // doc filename comes from the `--doc <ID>` token in its `remediation` (e.g. key // `fan_in` → doc `Fan-in.md`). Metric docs live in the neutral `base/` corpus. let key = id.to_ascii_lowercase(); - if let Some(rel) = snap + if let Some(doc) = snap .graphs .get("files") .and_then(|f| f.node_attributes.get(&key)) .and_then(|spec| spec.remediation.as_deref()) .and_then(crate::recommend::doc_ref) - .map(|doc| format!("base/{doc}.md")) { - return Some(rel); + // Metric docs default to the neutral `base/` corpus, but honor a language's + // doc override exactly as principle docs do: if the plugin routes its + // principle docs to a `<lang>/` folder (via `doc_overrides`) and actually + // ships a `<lang>/<doc>.md`, serve that; otherwise the shared `base/`. + if let Some(lang) = override_lang(snap) { + let rel = format!("{lang}/{doc}.md"); + if corpus_doc(&rel).is_some() { + return Some(rel); + } + } + return Some(format!("base/{doc}.md")); } // Fallback: any base corpus doc addressable by its filename stem // (case-insensitive) — covers docs that are neither a principle nor a metric: @@ -74,6 +83,19 @@ fn url_tail(url: &str) -> Option<String> { Some(after.split_whitespace().next()?.to_string()) } +/// The corpus folder a plugin's overridden docs resolve to (e.g. `rust`), inferred +/// from the principle `doc_url`s the config already routed through `doc_overrides`; +/// `None` when the plugin uses the shared `base/` corpus. Lets metric docs reuse the +/// same override decision without re-reading the plugin config here. +fn override_lang(snap: &Snapshot) -> Option<String> { + snap.principles + .iter() + .filter_map(|p| p.doc_url.as_deref()) + .filter_map(url_tail) + .filter_map(|rel| rel.split_once('/').map(|(l, _)| l.to_string())) + .find(|l| l != "base") +} + /// A language doc is a **manifest** (assembled from the base) when it carries at /// least one `<!-- doc:base … -->` include; a full standalone doc has none and is /// served verbatim. (A manifest may still write its own `# ` H1 + TL;DR head.) From ce3e1c49cd67278e22c87bb3a83b1d2a6a09b567 Mon Sep 17 00:00:00 2001 From: Roman Fedorov <Roman.Fedorov@constructor.tech> Date: Tue, 23 Jun 2026 23:24:48 +0300 Subject: [PATCH 24/40] test(e2e): regenerate report goldens for the front-loaded doc_note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The b32aee6 scaffolding change (doc_note front-load) is embedded verbatim in each report's prompt block, which the e2e goldens compare exactly — so all 9 went stale. Regenerated per docs/e2e.md (report + frozen header). Also re-freezes the rust golden's header, which had been committed with real machine values (branch/paths), and syncs versions 3.0.0-alpha.1 -> 4.0.0-alpha.1. Full workspace test suite green. --- .../c/tests/sample/code-ranker-report.json | 4 +-- .../cpp/tests/sample/code-ranker-report.json | 4 +-- .../tests/sample/code-ranker-report.json | 4 +-- .../go/tests/sample/code-ranker-report.json | 4 +-- .../tests/sample/code-ranker-report.json | 4 +-- .../tests/sample/code-ranker-report.json | 4 +-- .../tests/sample/code-ranker-report.json | 4 +-- .../rust/tests/sample/code-ranker-report.json | 28 +++++++++---------- .../tests/sample/code-ranker-report.json | 4 +-- 9 files changed, 30 insertions(+), 30 deletions(-) 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 237c7917..24d1ba7c 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 @@ -736,7 +736,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": "To understand the principle in detail, run `code-ranker report --doc {id}` — it prints the full principle to your terminal. Read it before proposing any changes.", + "doc_note": "**First, before reading the source**, run `code-ranker report --doc {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": [ @@ -769,7 +769,7 @@ } ], "versions": { - "code-ranker": "3.0.0-alpha.1" + "code-ranker": "4.0.0-alpha.1" }, "workspace": "/home/user/code-ranker" } 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 8227536a..9030fda0 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 @@ -746,7 +746,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": "To understand the principle in detail, run `code-ranker report --doc {id}` — it prints the full principle to your terminal. Read it before proposing any changes.", + "doc_note": "**First, before reading the source**, run `code-ranker report --doc {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": [ @@ -779,7 +779,7 @@ } ], "versions": { - "code-ranker": "3.0.0-alpha.1" + "code-ranker": "4.0.0-alpha.1" }, "workspace": "/home/user/code-ranker" } 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 cda49856..0eadb3ee 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 @@ -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": "To understand the principle in detail, run `code-ranker report --doc {id}` — it prints the full principle to your terminal. Read it before proposing any changes.", + "doc_note": "**First, before reading the source**, run `code-ranker report --doc {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": [ @@ -715,7 +715,7 @@ } ], "versions": { - "code-ranker": "3.0.0-alpha.1" + "code-ranker": "4.0.0-alpha.1" }, "workspace": "/home/user/code-ranker" } 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 52967dd3..de39ec50 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 @@ -684,7 +684,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": "To understand the principle in detail, run `code-ranker report --doc {id}` — it prints the full principle to your terminal. Read it before proposing any changes.", + "doc_note": "**First, before reading the source**, run `code-ranker report --doc {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": [ @@ -717,7 +717,7 @@ } ], "versions": { - "code-ranker": "3.0.0-alpha.1" + "code-ranker": "4.0.0-alpha.1" }, "workspace": "/home/user/code-ranker" } diff --git a/crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker-report.json b/crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker-report.json index 6ef567e8..a01c4018 100644 --- a/crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker-report.json +++ b/crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker-report.json @@ -925,7 +925,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": "To understand the principle in detail, run `code-ranker report --doc {id}` — it prints the full principle to your terminal. Read it before proposing any changes.", + "doc_note": "**First, before reading the source**, run `code-ranker report --doc {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": [ @@ -958,7 +958,7 @@ } ], "versions": { - "code-ranker": "3.0.0-alpha.1" + "code-ranker": "4.0.0-alpha.1" }, "workspace": "/home/user/code-ranker" } diff --git a/crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker-report.json b/crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker-report.json index 1468dbd4..44f9bda4 100644 --- a/crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker-report.json +++ b/crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker-report.json @@ -231,7 +231,7 @@ "plugin": "markdown", "prompt": { "cycle_note": "This is **one** dependency cycle; every module in it is listed below so the whole loop is visible. Fix one cycle at a time — `--top 2`+ lists several separate cycles at once and obscures how each one connects.", - "doc_note": "To understand the principle in detail, run `code-ranker report --doc {id}` — it prints the full principle to your terminal. Read it before proposing any changes.", + "doc_note": "**First, before reading the source**, run `code-ranker report --doc {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": [ @@ -264,7 +264,7 @@ } ], "versions": { - "code-ranker": "3.0.0-alpha.1" + "code-ranker": "4.0.0-alpha.1" }, "workspace": "/home/user/code-ranker" } 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 d5978e6f..6bbcf9b5 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 @@ -1034,7 +1034,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": "To understand the principle in detail, run `code-ranker report --doc {id}` — it prints the full principle to your terminal. Read it before proposing any changes.", + "doc_note": "**First, before reading the source**, run `code-ranker report --doc {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": [ @@ -1067,7 +1067,7 @@ } ], "versions": { - "code-ranker": "3.0.0-alpha.1" + "code-ranker": "4.0.0-alpha.1" }, "workspace": "/home/user/code-ranker" } 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 463b74a7..af2ba346 100644 --- a/crates/code-ranker-plugins/src/languages/rust/tests/sample/code-ranker-report.json +++ b/crates/code-ranker-plugins/src/languages/rust/tests/sample/code-ranker-report.json @@ -1,12 +1,12 @@ { "command": "code-ranker report crates/code-ranker-plugins/src/languages/rust/tests/sample --config crates/code-ranker-plugins/src/languages/rust/tests/sample/code-ranker.toml --output.json.path=crates/code-ranker-plugins/src/languages/rust/tests/sample/code-ranker-report.json", "config_file": "crates/code-ranker-plugins/src/languages/rust/tests/sample/code-ranker.toml", - "generated_at": "2026-06-19T17:17:39.418451Z", + "generated_at": "1970-01-01T00:00:00Z", "git": { - "branch": "feat/config-custom-checks", - "commit": "434336b99f71", - "dirty_files": 6, - "origin": "git@github.com:ffedoroff/code-ranker.git" + "branch": "main", + "commit": "000000000000", + "dirty_files": 0, + "origin": "git@example.com:org/repo.git" }, "graphs": { "files": { @@ -1722,7 +1722,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": "To understand the principle in detail, run `code-ranker report --doc {id}` — it prints the full principle to your terminal. Read it before proposing any changes.", + "doc_note": "**First, before reading the source**, run `code-ranker report --doc {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": [ @@ -1733,31 +1733,31 @@ ] }, "roots": { - "registry": "/Users/roman/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f", - "target": "/Users/roman/work/code-ranker/crates/code-ranker-plugins/src/languages/rust/tests/sample" + "registry": "/home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f", + "target": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/rust/tests/sample" }, "schema_version": "4.0", - "target": "/Users/roman/work/code-ranker/crates/code-ranker-plugins/src/languages/rust/tests/sample", + "target": "/home/user/code-ranker/crates/code-ranker-plugins/src/languages/rust/tests/sample", "timings": [ { "detail": "27 nodes from 25 files", - "ms": 411, + "ms": 0, "stage": "rust" }, { "detail": "25 nodes annotated", - "ms": 63, + "ms": 0, "stage": "complexity" }, { "detail": "nodes=27 edges=48", - "ms": 43, + "ms": 0, "stage": "projection" } ], "versions": { - "code-ranker": "3.0.0-alpha.1", + "code-ranker": "4.0.0-alpha.1", "rustc": "1.96.0" }, - "workspace": "/Users/roman/work/code-ranker" + "workspace": "/home/user/code-ranker" } diff --git a/crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker-report.json b/crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker-report.json index e56dccb6..c5e7afe9 100644 --- a/crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker-report.json +++ b/crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker-report.json @@ -992,7 +992,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": "To understand the principle in detail, run `code-ranker report --doc {id}` — it prints the full principle to your terminal. Read it before proposing any changes.", + "doc_note": "**First, before reading the source**, run `code-ranker report --doc {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": [ @@ -1025,7 +1025,7 @@ } ], "versions": { - "code-ranker": "3.0.0-alpha.1" + "code-ranker": "4.0.0-alpha.1" }, "workspace": "/home/user/code-ranker" } From 8eded7f21497ecc64a2dbc53292012427d41e3d6 Mon Sep 17 00:00:00 2001 From: Roman Fedorov <Roman.Fedorov@constructor.tech> Date: Wed, 24 Jun 2026 00:39:53 +0300 Subject: [PATCH 25/40] doc(rust HK): make the audiences diagnosis the mandatory first step haiku-HK on cyberfabric-core's gear.rs (HK 149M) read the doc, picked 'split by role', but split the hub by its own internal seams (one file per trait impl) -> shaved sloc 761->345, fan_in ROSE 9->13, HK only -34%, gear still #1. opus/sonnet instead ran the audiences check, found 8-9 consumers all reached in for one wrong-audience type (AppServices), moved it to a leaf -> fan_in ->1/0, HK ~-100%. Reframe 'Remedies, in order' as audiences-FIRST: trace what each fan-in consumer imports; the answer's shape picks the remedy (same item for many -> move to leaf; visibility path -> narrow; different parts -> split by role). Add an explicit trap: splitting a hub by its internal seams shaves sloc without cutting coupling. Hypothesis (prompt-eval): haiku-HK-2 on gear.rs runs the audiences check and does the wrong-audience extraction (gear.rs HK ~-100%, q2->q5) instead of the capability split. --- languages/rust/HK.md | 63 +++++++++++++++++++++++++------------------- 1 file changed, 36 insertions(+), 27 deletions(-) diff --git a/languages/rust/HK.md b/languages/rust/HK.md index 94ce6362..3e8aef79 100644 --- a/languages/rust/HK.md +++ b/languages/rust/HK.md @@ -16,33 +16,42 @@ imports widely: in serialization, validation, and persistence concerns. - A `utils.rs` junk drawer that accumulates helpers used everywhere. -### Remedies, in order - -**1. Narrow artificial fan-in first.** Before anything structural, check whether -the hub's in-edges are real `use` imports or just over-broad -`pub(in <ancestor>)` visibility. If artificial, narrow the visibility -(`pub(super)` if only the parent uses the item, `pub(crate)` if a sibling subtree -does): the edge dissolves, `fan_in` drops, and HK falls — a one-line change, no -split. (`HK = sloc × (fan_in × fan_out)²`, so dropping `fan_in` from 5 to 2 alone -is a ~6× cut.) - -**2. The highest-value fix: split a multi-role hub by responsibility.** When the -in-edges are *real*, the most valuable thing you can do is separate a component -that has accreted **2–3 distinct roles** into **one module per role**. A file -that does, say, *field mapping* **and** *platform-API orchestration* **and** -*status handling* is three components wearing one name; giving each its own -module is what genuinely cuts HK, because each role then couples only to its own -dependencies — **both `fan_in` and `fan_out` drop for real**, and each piece -becomes independently testable and changeable. Find the seams (distinct -responsibilities, distinct dependency sets) and cut along them. - -**3. Do not shave `sloc` by mechanical splitting.** Moving a type declaration -away from its `impl`, or hoisting a trait into a sibling file, splits **one -cohesive role** across two files. It lowers the HK *number* (less `sloc`) without -separating any responsibility — and can even *raise* coupling by widening -visibility to make the move compile. That is metric-gaming, not decoupling; see -"When a hub is legitimate" below. The test: if your split does not leave each -new module owning a **distinct role**, it is not a real HK fix. +### Diagnose first: who imports this hub, and for what? + +**Before choosing any remedy, run the _audiences_ check** — for each fan-in edge, +look at what that consumer actually imports from the hub. The remedy is decided by +the *shape* of that answer, **not** by the hub's own internal structure. Skipping +this step is the most common way to "fix" HK and barely move it. + +`HK = sloc × (fan_in × fan_out)²` — the coupling term is squared, so the goal is +always to cut **how many edges reach the hub, or what they reach for** — never to +chase `sloc`. + +- **Many consumers reach in for the SAME one or two items** (a shared type/alias, a + constant, a trait) → those items live in the **wrong home**. **Move them to a new + leaf module** and repoint the consumers. `fan_in` collapses (the edges now land on + a leaf with no fan-out of its own), the hub keeps only what it genuinely uses, and + nothing is split. On a real hub this is usually the **biggest, cheapest win** — and + it is exactly the one a size-based split misses. +- **The in-edge is only a `pub(in <ancestor>)` visibility path**, not a real `use` → + **narrow the visibility** (`pub(super)` if only the parent uses the item, + `pub(crate)` if a sibling subtree does). The phantom edge dissolves; one-line change. +- **Consumers genuinely need DIFFERENT parts of the hub** (group A imports one cluster + of items, group B another) → **split the hub by responsibility**, one module per + role, so each consumer depends only on the part it uses. Both `fan_in` and `fan_out` + drop for real, and each piece becomes independently testable. + +### The trap: splitting the hub by its own internal seams + +Carving a hub into sub-files along its *internal* structure — one file per trait +`impl`, a type-decl moved away from its `impl`, a `worker`/`runtime` helper — **shaves +`sloc` without cutting coupling**, and often *raises* `fan_in` (the new sub-files now +import the parent). The HK number drops a little; the hub stays the worst module. That +is metric-gaming, not decoupling. **A split is a real HK fix only when it changes _who +depends on what_** — i.e. when it follows the audiences check above, not the hub's own +table of contents. If the audiences check shows the coupling is a shared item in the +wrong home, **move that item out; do not carve up the hub.** (See "When a hub is +legitimate" below before splitting a genuine orchestrator.) <!-- doc:base "Reducing it" --> ## When a hub is legitimate (accept, don't game) From 13f4c8f6fdb5ed621c79dc35cd7dc3338989424d Mon Sep 17 00:00:00 2001 From: Roman Fedorov <Roman.Fedorov@constructor.tech> Date: Wed, 24 Jun 2026 00:47:10 +0300 Subject: [PATCH 26/40] Revert "doc(rust HK): make the audiences diagnosis the mandatory first step" This reverts commit 919de6cc9c002a187145f762ec016f2e551edb4a. --- languages/rust/HK.md | 63 +++++++++++++++++++------------------------- 1 file changed, 27 insertions(+), 36 deletions(-) diff --git a/languages/rust/HK.md b/languages/rust/HK.md index 3e8aef79..94ce6362 100644 --- a/languages/rust/HK.md +++ b/languages/rust/HK.md @@ -16,42 +16,33 @@ imports widely: in serialization, validation, and persistence concerns. - A `utils.rs` junk drawer that accumulates helpers used everywhere. -### Diagnose first: who imports this hub, and for what? - -**Before choosing any remedy, run the _audiences_ check** — for each fan-in edge, -look at what that consumer actually imports from the hub. The remedy is decided by -the *shape* of that answer, **not** by the hub's own internal structure. Skipping -this step is the most common way to "fix" HK and barely move it. - -`HK = sloc × (fan_in × fan_out)²` — the coupling term is squared, so the goal is -always to cut **how many edges reach the hub, or what they reach for** — never to -chase `sloc`. - -- **Many consumers reach in for the SAME one or two items** (a shared type/alias, a - constant, a trait) → those items live in the **wrong home**. **Move them to a new - leaf module** and repoint the consumers. `fan_in` collapses (the edges now land on - a leaf with no fan-out of its own), the hub keeps only what it genuinely uses, and - nothing is split. On a real hub this is usually the **biggest, cheapest win** — and - it is exactly the one a size-based split misses. -- **The in-edge is only a `pub(in <ancestor>)` visibility path**, not a real `use` → - **narrow the visibility** (`pub(super)` if only the parent uses the item, - `pub(crate)` if a sibling subtree does). The phantom edge dissolves; one-line change. -- **Consumers genuinely need DIFFERENT parts of the hub** (group A imports one cluster - of items, group B another) → **split the hub by responsibility**, one module per - role, so each consumer depends only on the part it uses. Both `fan_in` and `fan_out` - drop for real, and each piece becomes independently testable. - -### The trap: splitting the hub by its own internal seams - -Carving a hub into sub-files along its *internal* structure — one file per trait -`impl`, a type-decl moved away from its `impl`, a `worker`/`runtime` helper — **shaves -`sloc` without cutting coupling**, and often *raises* `fan_in` (the new sub-files now -import the parent). The HK number drops a little; the hub stays the worst module. That -is metric-gaming, not decoupling. **A split is a real HK fix only when it changes _who -depends on what_** — i.e. when it follows the audiences check above, not the hub's own -table of contents. If the audiences check shows the coupling is a shared item in the -wrong home, **move that item out; do not carve up the hub.** (See "When a hub is -legitimate" below before splitting a genuine orchestrator.) +### Remedies, in order + +**1. Narrow artificial fan-in first.** Before anything structural, check whether +the hub's in-edges are real `use` imports or just over-broad +`pub(in <ancestor>)` visibility. If artificial, narrow the visibility +(`pub(super)` if only the parent uses the item, `pub(crate)` if a sibling subtree +does): the edge dissolves, `fan_in` drops, and HK falls — a one-line change, no +split. (`HK = sloc × (fan_in × fan_out)²`, so dropping `fan_in` from 5 to 2 alone +is a ~6× cut.) + +**2. The highest-value fix: split a multi-role hub by responsibility.** When the +in-edges are *real*, the most valuable thing you can do is separate a component +that has accreted **2–3 distinct roles** into **one module per role**. A file +that does, say, *field mapping* **and** *platform-API orchestration* **and** +*status handling* is three components wearing one name; giving each its own +module is what genuinely cuts HK, because each role then couples only to its own +dependencies — **both `fan_in` and `fan_out` drop for real**, and each piece +becomes independently testable and changeable. Find the seams (distinct +responsibilities, distinct dependency sets) and cut along them. + +**3. Do not shave `sloc` by mechanical splitting.** Moving a type declaration +away from its `impl`, or hoisting a trait into a sibling file, splits **one +cohesive role** across two files. It lowers the HK *number* (less `sloc`) without +separating any responsibility — and can even *raise* coupling by widening +visibility to make the move compile. That is metric-gaming, not decoupling; see +"When a hub is legitimate" below. The test: if your split does not leave each +new module owning a **distinct role**, it is not a real HK fix. <!-- doc:base "Reducing it" --> ## When a hub is legitimate (accept, don't game) From 9cfb2ed3cc095ce4e042c12f1c4155acee3441f0 Mon Sep 17 00:00:00 2001 From: Roman Fedorov <Roman.Fedorov@constructor.tech> Date: Wed, 24 Jun 2026 00:49:43 +0300 Subject: [PATCH 27/40] meta(prompt-eval): distinguish a prompt gap from a capability ceiling If the agent reads the lever and still doesn't do the named step (and a stronger model on the same prompt does), the gap is model capability, not the prompt: revert the lever (failed hypothesis), record a capability ceiling, stop -- don't burn the 3rd iteration. Observed on cyberfabric-core gear.rs HK (haiku, two reverted levers). --- contrib/prompting-self-improve.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/contrib/prompting-self-improve.md b/contrib/prompting-self-improve.md index c5c8bde5..e5c3c2ae 100644 --- a/contrib/prompting-self-improve.md +++ b/contrib/prompting-self-improve.md @@ -431,6 +431,21 @@ project: a change should help the failure **class**, not memorise the repo. Stop a tier after **3 iterations** even if not perfect — record the residual gap (the row stays in `metrics.csv`) so it's a decision on record, not a silent failure. +**Distinguish a prompt gap from a capability ceiling.** A lever can only fix what the +model *would have done with the right instruction*. If the agent **reads the lever** +(the doc/section it targets shows in the transcript) and **still doesn't perform the +named step** — and a stronger model on the *same* prompt does — then the gap is the +model's diagnostic ability, not the prompt. Signs: the targeted column doesn't move (or +worsens) across two iterations, and the agent substitutes a plausible-but-wrong move it +*can* do (e.g. on HK, splitting a hub by its internal seams instead of running the +audiences analysis to find the wrong-audience import). When you see this, **revert the +lever** (it failed its hypothesis — keeping it is lever-creep), record the residual as a +**capability ceiling for that tier on that problem class**, and stop — don't spend the +3rd iteration refining a prompt the model isn't acting on. (Observed: cyberfabric-core +`gear.rs` HK — opus/sonnet ran the audiences check and dissolved the hub to ~0; haiku, +under two successive HK levers it demonstrably read, twice did sloc-shaving splits that +left `fan_in` untouched and the hub still #1.) + ## The meta-loop — improving this playbook The prompts are levers; so is this file. After a sweep — and the **moment the user has From 00581b19521b9e2a030fdd7c92378cda87fe16cc Mon Sep 17 00:00:00 2001 From: Roman Fedorov <Roman.Fedorov@constructor.tech> Date: Wed, 24 Jun 2026 09:20:08 +0300 Subject: [PATCH 28/40] meta(prompt-eval): workspace-scope verification + sweep mode lessons From the cyberfabric-core cycle sweep (20 cycles -> 0, 28 Haiku passes): - step 5 / tests_pass: per-crate green is NOT enough on a multi-crate workspace; gate on cargo check --workspace AND cargo test --workspace --no-run (feature- unified + cfg(test) paths break while the touched crate stays green). Also watch the test COUNT (a fix can drop tests and leave survivors green). - new 'Sweeps' subsection: track net progress (stall on 2 no-net-decrease passes; cap iters), gate compilation at workspace scope between passes / checkpoint, measure from artifacts not the agent's summary, and the cheap-tier-converges-on- cycles-but-not-HK-hubs finding. --- contrib/prompting-self-improve.md | 36 ++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/contrib/prompting-self-improve.md b/contrib/prompting-self-improve.md index e5c3c2ae..5986b453 100644 --- a/contrib/prompting-self-improve.md +++ b/contrib/prompting-self-improve.md @@ -163,6 +163,15 @@ nothing eval-related is left in `PROJECT`. work out how on its own** — which command to run, which doc to read, which refactor to choose. Don't hand it the command: the run tests whether the prompt and docs lead it there. The agent proposes the plan, applies the fix, and runs the project's tests. + **Verify at workspace scope, not just the touched crate.** A multi-crate workspace + (e.g. a Cargo workspace) needs BOTH `cargo check --workspace` AND + `cargo test --workspace --no-run` (build the test profile) before `tests_pass` is + trustworthy — a per-crate `cargo test -p <crate>` passes green while the change still + breaks the workspace through a **feature-unified** path (a sibling crate enables a + feature that compiles code the standalone build skipped) or a **`#[cfg(test)]`** module + that only the test profile compiles. Both bit the cyberfabric-core sweep: a visibility + narrowing that the touched crate's own tests accepted left a downstream/feature-gated + reference pointing at a now-private item. 6. **AFTER + DIFF.** `code-ranker report . --baseline $RUN/before.json --output.html.path=$RUN/diff.html --output.json.path=$RUN/after.json` (+ an `after.html`). 7. **Collect the agent's own writes into `$RUN`.** The generated prompt tells the agent to save a plan to `<PROJECT>/.code-ranker/<ts>-<FOCUS>.md`, and any `report` it runs @@ -190,6 +199,31 @@ nothing eval-related is left in `PROJECT`. --project-path PROJECT --quality <1-5> --clarity <1-5> --verdict improved ``` +### Sweeps — clearing every instance, not tuning the prompt + +A *sweep* (loop the agent over `--top 1` until a FOCUS hits zero) is a different mode from +a single tuning run, and it has its own failure shape: fixes **accumulate in one working +tree** across many passes, so a later pass silently breaks an earlier one and per-pass +verification compounds the debt. Rules learned from the cyberfabric-core cycle sweep +(20 cycles → 0 over 28 Haiku passes): + +- **Track net progress, not per-pass success.** Measure the FOCUS total (e.g. sum of cycle + members) each pass; **stop on no net decrease across 2 passes** (a stall / capability + ceiling) and cap total iterations. A pass that *fragments* a big SCC (breaks one back-edge, + leaves a smaller remnant) shows as flat cycle-count but falling members — that is progress; + full convergence is normal even when individual passes are partial. +- **Gate compilation at workspace scope between passes (or checkpoint).** Since the tree + accumulates, a per-crate-green pass can still break the workspace (feature-unified / + `cfg(test)` paths, see step 5). Either commit/checkpoint per pass for bisectability, or run + `cargo check --workspace` periodically — and always `cargo check --workspace` + + `cargo test --workspace --no-run` + a full test run at the **end** before declaring done. +- **Measure from artifacts, never the agent's summary.** Agents over-claim ("`fan_in` → 0", + "cycle gone") — re-derive the count from `report --output.json` each pass. +- **Cheap tier + iteration converges on cycles** even with fragmentation, because the cycle's + back-edges are concrete in the prompt's connections list; it does **not** converge on HK + hubs, where finding the high-value cut needs reasoning the cheapest tier lacks (the + capability ceiling — see the Tuning rule). + ## Artifacts: layout & naming Everything lives under the **code-ranker repo's own `.code-ranker/`** (this repo, @@ -309,7 +343,7 @@ Columns, grouped by objective (most are extractable from the run's artifacts; th | Column | Axis | Source | Meaning (↑/↓ = better) | |---|---|---|---| | `ts`,`cr_sha`,`project`,`focus`,`model`,`iter`,`run` | id | run.md | identity — `cr_sha` is the prompt version | -| `tests_pass` | quality | project tests | 1/0 — tests green, behaviour preserved | +| `tests_pass` | quality | project tests | 1/0 — tests green, behaviour preserved. ⚠ On a multi-crate workspace a per-crate pass is **not** sufficient — gate on `cargo check --workspace` + `cargo test --workspace --no-run` (see step 5); the collector's heuristic also can't see a workspace/feature/`cfg(test)` break, so verify it yourself. Also watch the **test count**: a fix that drops tests (e.g. moved code without migrating its tests) can leave the survivors green — `tests_pass` won't catch the lost coverage | | `focus_before` / `focus_after` | quality | before/after `.json` | **Focus-aware.** Cycle FOCUS (`ADP`/`cycle`): total cycle-warning count. Metric FOCUS (`HK`, `sloc`, `cognitive`, …): the **project-wide sum** of that metric across module nodes — a flat total beside a dropped `worst_*` means the fix **relocated** the cost rather than dissolving it. | | `focus_delta` | quality | `after − before` | ↓ (negative) = better (fewer cycle warnings, or lower total metric) | | `worst_before` / `worst_after` | quality | before/after `.json` | worst instance: cycle FOCUS → largest SCC node count; metric FOCUS → the **worst module's metric value** (the `--top 1` target, e.g. HK 390825 → 140697). Direction from the snapshot's `node_attributes` schema (`higher_better` → worst is the min). | From 2b586ab09118aa332cfd6a01daf66a27f5266f2a Mon Sep 17 00:00:00 2001 From: Roman Fedorov <Roman.Fedorov@constructor.tech> Date: Wed, 24 Jun 2026 15:40:08 +0300 Subject: [PATCH 29/40] feat(viewer): lowercase brand + version-on-hover, toggleable stat button - header brand now reads 'code-ranker' (not upper-cased); the exact tool version (snapshot.versions["code-ranker"]) appears small, centred, under the brand on header hover (injected by lib.rs for the __CR_VERSION__ placeholder) - the 'stat' button now toggles the diff-summary popup (second click closes) and carries an .active highlight while open docs: README lists supported languages (Rust stable, the other eight beta); viewer DESIGN/PRD updated for the brand/version + stat toggle. --- README.md | 4 ++-- crates/code-ranker-viewer/src/assets/base.css | 14 ++++++++++---- crates/code-ranker-viewer/src/assets/export.css | 3 +++ crates/code-ranker-viewer/src/assets/index.html | 2 +- crates/code-ranker-viewer/src/assets/summary.js | 6 +++++- crates/code-ranker-viewer/src/lib.rs | 9 +++++++++ docs/code-ranker-viewer/DESIGN.md | 4 ++-- docs/code-ranker-viewer/PRD.md | 5 +++-- 8 files changed, 35 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 8a52abc7..62091b20 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![PyPI](https://img.shields.io/pypi/v/code-ranker.svg)](https://pypi.org/project/code-ranker/) [![License](https://img.shields.io/crates/l/code-ranker.svg)](./LICENSE) -Structural-analysis tool for **Rust, Python, JavaScript and TypeScript** codebases. Built **AI-agent-friendly first** — finds where a project has structural problems and hands an actionable shortlist to a human or an AI agent for the actual refactor. +Structural-analysis tool for **Rust** (production-ready) plus **Python, TypeScript/JavaScript, Go, C, C++, C# and Markdown** (beta) codebases. Built **AI-agent-friendly first** — finds where a project has structural problems and hands an actionable shortlist to a human or an AI agent for the actual refactor. **👉 Map your codebase's worst structural problems in 30 seconds — [jump to the Rust quick start](#rust-quick-start) and run it on your repo now.** @@ -106,7 +106,7 @@ code-ranker report code-ranker report . --baseline .code-ranker/before.json ``` -Built-in plugins: `rust` (cargo + syn), `python`, `javascript` (also handles TypeScript) — all compiled into the single binary, nothing to install. +Built-in plugins for all nine supported languages (`rust` uses cargo + syn; Rust is production-ready, the rest are beta) — all compiled into the single binary, nothing to install. ## Documentation diff --git a/crates/code-ranker-viewer/src/assets/base.css b/crates/code-ranker-viewer/src/assets/base.css index cec84b50..1b74a6eb 100644 --- a/crates/code-ranker-viewer/src/assets/base.css +++ b/crates/code-ranker-viewer/src/assets/base.css @@ -4,10 +4,16 @@ body { margin: 0; background: #fafafa; color: #222; } header { background: #2c3e50; color: #fff; padding: 8px 24px; } .header-row { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; min-height: 32px; } -.header-brand { font-size: 15px; font-weight: 800; letter-spacing: .12em; - color: #1abc9c; text-transform: uppercase; white-space: nowrap; margin-right: 4px; - text-decoration: none; } -a.header-brand:hover { text-decoration: underline; } +.header-brand { position: relative; display: inline-flex; align-items: baseline; + white-space: nowrap; margin-right: 4px; text-decoration: none; } +.header-brand-name { font-size: 15px; font-weight: 800; letter-spacing: .02em; color: #1abc9c; } +/* Exact version: sits just under the brand, absolutely positioned so it never + grows the header; hidden until the header is hovered, then fades in. */ +.header-ver { position: absolute; top: 100%; left: 50%; transform: translateX(-50%); + font-size: 10px; font-weight: 500; letter-spacing: 0; white-space: nowrap; + color: #cdd8e6; opacity: 0; transition: opacity .12s ease; } +header:hover .header-ver { opacity: 1; } +a.header-brand:hover .header-brand-name { text-decoration: underline; } /* Project title: capped width with an ellipsis (full value in the `title` attr). */ .header-title { font-size: 18px; font-weight: 500; white-space: nowrap; color: #d7e0ea; display: inline-block; max-width: 340px; overflow: hidden; diff --git a/crates/code-ranker-viewer/src/assets/export.css b/crates/code-ranker-viewer/src/assets/export.css index 8e735513..e9190cc3 100644 --- a/crates/code-ranker-viewer/src/assets/export.css +++ b/crates/code-ranker-viewer/src/assets/export.css @@ -163,6 +163,9 @@ border: 0; border-radius: 4px; padding: 6px 14px; cursor: pointer; transition: background .15s; } .header-stats-btn:hover { background: #5d7593; } +/* Highlighted while the stats popup is open (toggle). */ +.header-stats-btn.active { background: #1abc9c; color: #14202c; font-weight: 600; } +.header-stats-btn.active:hover { background: #1fd0b0; } /* Full-screen WHITE fill (the page header stays visible above it — its top offset is set in JS). The popup itself is content-width and vertically centred. */ diff --git a/crates/code-ranker-viewer/src/assets/index.html b/crates/code-ranker-viewer/src/assets/index.html index 5a9fc8c4..9680feb9 100644 --- a/crates/code-ranker-viewer/src/assets/index.html +++ b/crates/code-ranker-viewer/src/assets/index.html @@ -12,7 +12,7 @@ <header> <div class="header-row"> - <a class="header-brand" href="https://github.com/ffedoroff/code-ranker" target="_blank" rel="noopener" title="code-ranker on GitHub">CODE RANKER</a> + <a class="header-brand" href="https://github.com/ffedoroff/code-ranker" target="_blank" rel="noopener" title="code-ranker on GitHub"><span class="header-brand-name">code-ranker</span><span class="header-ver">__CR_VERSION__</span></a> <span class="header-title" id="title"></span> <div class="header-meta"> <!-- Each snapshot control shows its branch + commit. Click the body → show diff --git a/crates/code-ranker-viewer/src/assets/summary.js b/crates/code-ranker-viewer/src/assets/summary.js index 7c208384..ea0b332f 100644 --- a/crates/code-ranker-viewer/src/assets/summary.js +++ b/crates/code-ranker-viewer/src/assets/summary.js @@ -429,6 +429,7 @@ function openSummaryPopup(syncUrl = true) { const ov = document.getElementById('summary-overlay'); if (!ov) return; window._statsOpen = true; + document.getElementById('stats-btn')?.classList.add('active'); buildSummary(); // refresh to the active side/stat before showing // Keep the page header visible: start the white fill just below it. const hdr = document.querySelector('header'); @@ -440,6 +441,7 @@ function openSummaryPopup(syncUrl = true) { function closeSummaryPopup(syncUrl = true) { const ov = document.getElementById('summary-overlay'); window._statsOpen = false; + document.getElementById('stats-btn')?.classList.remove('active'); if (ov) ov.style.display = 'none'; document.body.style.overflow = ''; if (syncUrl) window.navReplaceView?.(); @@ -451,7 +453,9 @@ function setupSummaryPopup() { const ov = document.getElementById('summary-overlay'); if (!ov || ov._wired) return; ov._wired = true; - document.getElementById('stats-btn')?.addEventListener('click', openSummaryPopup); + document.getElementById('stats-btn')?.addEventListener('click', () => { + window._statsOpen ? closeSummaryPopup() : openSummaryPopup(); + }); document.getElementById('summary-close')?.addEventListener('click', closeSummaryPopup); // Footer: download links (.json / .md) + copy-to-clipboard buttons. document.getElementById('summary-dl-json')?.addEventListener('click', e => { e.preventDefault(); exportSummaryJSON(); }); diff --git a/crates/code-ranker-viewer/src/lib.rs b/crates/code-ranker-viewer/src/lib.rs index 084c5e93..833479d1 100644 --- a/crates/code-ranker-viewer/src/lib.rs +++ b/crates/code-ranker-viewer/src/lib.rs @@ -87,7 +87,16 @@ pub fn render_html_viewer(baseline: Option<&Snapshot>, current: Option<&Snapshot embed("cs-current", current), ); + // Exact code-ranker version that produced the analysed snapshot (the CLI's + // `CARGO_PKG_VERSION`, recorded under `versions["code-ranker"]`), shown small + // under the header brand. Fall back to this crate's version if absent. + let cr_version = current + .or(baseline) + .and_then(|s| s.versions.get("code-ranker")) + .map_or(env!("CARGO_PKG_VERSION"), String::as_str); + ASSET_HTML + .replace("__CR_VERSION__", &format!("v{cr_version}")) .replace( r#"<link rel="stylesheet" href="./index.css">"#, &format!( diff --git a/docs/code-ranker-viewer/DESIGN.md b/docs/code-ranker-viewer/DESIGN.md index 295c3baa..2dff9f68 100644 --- a/docs/code-ranker-viewer/DESIGN.md +++ b/docs/code-ranker-viewer/DESIGN.md @@ -113,7 +113,7 @@ top-to-bottom). The viewer was split out of three former monoliths (`diagram.js` | File | Purpose | |------|---------| | `node-table.js` | Sortable **Details** table (collapsed by default, re-titled per active side, search + **kind-filter checkboxes** when expanded (hidden while collapsed, like the search box) — files / folders / `<groups>`, with files + groups on by default (folders off), selection checkboxes). Besides file rows it lists synthetic **folder** and **group (crate)** aggregate rows (`buildAggregates` — each numeric column **summed** over the member files, a distinct `kind`, `_cat`-tagged); each aggregate carries a selection checkbox and a `cycle` count (member files in a dependency cycle, blank at 0). Clicking an aggregate **drills** into it on the map (`focusFolderTarget`/`drillIntoGroup`, like clicking its SVG box) while a file row opens the modal. Hovering/selecting an aggregate row lights up its on-map element and vice-versa via a shared key (`group:<crate>` / `folder:<dir>`, `section._gAggMap`) — best-effort, only when that element is currently rendered (crate boxes in the overview). The average/count footer (with percentile tooltips) is shown **only when the displayed rows are a single kind**. An empty / omitted metric cell **sorts as the minimum** — with the sort direction (first ascending, last descending), never pinned to one end. Hosts the **Prompt Generator** button. `attachModalCheckbox`/`setupNodeTable`. | -| `summary.js` | Review/diff **summary** table. Rows come from a **builder registry** (id → `<tr>` HTML, `''` to skip), and the display order is a **`LAYOUT` tree** of titled sections — each `{ title, rows: [...ids] }` — so reordering rows or sections is a pure data edit. Structural rows (`nodes`, `groups` = distinct grouping-key values, `edges`, `cycles`) are plain **counts**; `metric:<key>` rows show one **per-file statistic** chosen by a radio rendered as an **in-table divider row** (`{ radio: true }` in `LAYOUT`, placed between the count rows and the metric sections it drives; handler delegated on the tbody — `setupSummaryStatControl` — `avg`/`min`/`p50`/`p90`/`max`/`sum`, default `avg`; `sum` aggregates over files, the rest read `nodePercentiles`); changing it re-renders the table and round-trips through the URL (`stat=`, omitted for the default `avg`). Each section emits a `summary-subhead` divider row; a metric the snapshot lacks renders nothing, and a section left with no rows **drops its header**. Any metric builder not placed in `LAYOUT` lands in a trailing **Other** section so a new metric never silently vanishes. The table is shown in a **full-screen popup** opened by the **`stat`** button in the page header (`setupSummaryPopup` — `#summary-overlay`, closed by ✕/Esc/backdrop; its open state round-trips through the URL `panel=stats` so a refresh reopens it); each render also stashes a structured `window._summaryModel` (sections → `{label, baseline, current, delta}`) that the **header export controls** (next to ✕, one group per format) turn into **download** (`.json` / `.md`) and **copy-to-clipboard** icon buttons — each an SVG icon with a `title`/`aria-label` tooltip; copy briefly swaps the icon for a ✓ — all client-side, no network (`summaryJSONText`/`summaryMarkdownText` feed both `downloadFile` and `copySummaryText`). Downloads are named after the current HTML report file (`summaryFileBase`: `…-diff.html` → `…-diff-report.json`/`.md`, falling back to the analysis target). The hover tooltip shows `avg`, `min`, `max` plus the p1/p10/p50/p90/p99 distribution. `direction`-driven Δ colouring (`effectiveDir(key, stat)`: neutral = uncoloured, and the **`sum`** stat is always uncoloured — its delta tracks the change in file count, not per-file quality; every other stat is count-normalised so the metric's own direction applies); a delta whose **rounded** magnitude is 0 renders as a plain uncoloured `0` (never `+0`/`−0`), and the Δ header column is labelled `Δ delta`. | +| `summary.js` | Review/diff **summary** table. Rows come from a **builder registry** (id → `<tr>` HTML, `''` to skip), and the display order is a **`LAYOUT` tree** of titled sections — each `{ title, rows: [...ids] }` — so reordering rows or sections is a pure data edit. Structural rows (`nodes`, `groups` = distinct grouping-key values, `edges`, `cycles`) are plain **counts**; `metric:<key>` rows show one **per-file statistic** chosen by a radio rendered as an **in-table divider row** (`{ radio: true }` in `LAYOUT`, placed between the count rows and the metric sections it drives; handler delegated on the tbody — `setupSummaryStatControl` — `avg`/`min`/`p50`/`p90`/`max`/`sum`, default `avg`; `sum` aggregates over files, the rest read `nodePercentiles`); changing it re-renders the table and round-trips through the URL (`stat=`, omitted for the default `avg`). Each section emits a `summary-subhead` divider row; a metric the snapshot lacks renders nothing, and a section left with no rows **drops its header**. Any metric builder not placed in `LAYOUT` lands in a trailing **Other** section so a new metric never silently vanishes. The table is shown in a **full-screen popup** **toggled** by the **`stat`** button in the page header (`setupSummaryPopup` — `#summary-overlay`; the button carries an `.active` highlight while open, a second click closes it, as do ✕/Esc/backdrop; its open state round-trips through the URL `panel=stats` so a refresh reopens it); each render also stashes a structured `window._summaryModel` (sections → `{label, baseline, current, delta}`) that the **header export controls** (next to ✕, one group per format) turn into **download** (`.json` / `.md`) and **copy-to-clipboard** icon buttons — each an SVG icon with a `title`/`aria-label` tooltip; copy briefly swaps the icon for a ✓ — all client-side, no network (`summaryJSONText`/`summaryMarkdownText` feed both `downloadFile` and `copySummaryText`). Downloads are named after the current HTML report file (`summaryFileBase`: `…-diff.html` → `…-diff-report.json`/`.md`, falling back to the analysis target). The hover tooltip shows `avg`, `min`, `max` plus the p1/p10/p50/p90/p99 distribution. `direction`-driven Δ colouring (`effectiveDir(key, stat)`: neutral = uncoloured, and the **`sum`** stat is always uncoloured — its delta tracks the change in file count, not per-file quality; every other stat is count-normalised so the metric's own direction applies); a delta whose **rounded** magnitude is 0 renders as a plain uncoloured `0` (never `+0`/`−0`), and the Δ header column is labelled `Δ delta`. | ### Export @@ -135,7 +135,7 @@ top-to-bottom). The viewer was split out of three former monoliths (`diagram.js` | File | Purpose | |------|---------| -| `index.html` | The shell: one `<header>` row (brand — a link to the GitHub repo, title, two snapshot controls + a toggle, then a **`stat`** button), the single Files `.view` with `.frame-wrap` (svg frame, the navigation **breadcrumb** top-left — tier-dropdown anchor, path chips, trailing reveal-depth **lens chip** — zoom/size controls incl. a **cycle** filter toggle, kbd legend), and the full-screen **diff-summary popup** (`#summary-overlay`). | +| `index.html` | The shell: one `<header>` row (brand — a link to the GitHub repo reading **`code-ranker`** (not upper-cased), with the exact tool version from `snapshot.versions["code-ranker"]` — injected by `lib.rs` for the `__CR_VERSION__` placeholder — shown small and centred under it on hover; title, two snapshot controls + a toggle, then a **`stat`** button that toggles the diff-summary popup and stays highlighted while it is open), the single Files `.view` with `.frame-wrap` (svg frame, the navigation **breadcrumb** top-left — tier-dropdown anchor, path chips, trailing reveal-depth **lens chip** — zoom/size controls incl. a **cycle** filter toggle, kbd legend), and the full-screen **diff-summary popup** (`#summary-overlay`). | | `base.css` · `map.css` · `modal.css` · `tables.css` · `export.css` · `snap.css` · `map-svg.css` | The former `index.css` split by concern; concatenated in `lib.rs` **in source order** into one inlined `<style>` (preserving the cascade, no extra requests → keeps the offline guarantee). `map-svg.css` holds the graphviz node/edge state rules: visibility toggles, **cycle red stroke** (side-gated), selection, hover, status bar and edge highlight. | ## Navigation: tier, focus & reveal depth diff --git a/docs/code-ranker-viewer/PRD.md b/docs/code-ranker-viewer/PRD.md index 9a864f06..e57eabe3 100644 --- a/docs/code-ranker-viewer/PRD.md +++ b/docs/code-ranker-viewer/PRD.md @@ -338,8 +338,9 @@ offline HTML report (named `…-diff.html`) displaying: highlighting - `external` library nodes shown in a distinct amber colour with dashed edges to distinguish them from internal file edges -- Diff summary: a full-screen overlay (opened by the header **stat** - button; the page header stays visible) of structural counts +- Diff summary: a full-screen overlay (**toggled** by the header **stat** + button — it stays highlighted while open and closes on a second click; + the page header stays visible) of structural counts (files/folders/groups/edges/nodes-in-cycles) and per-file metric statistics (avg/min/p50/p90/max/sum, picked by a radio and persisted in the URL `stat=`), baseline vs current with Δ, downloadable as **JSON** or **Markdown** From 52f73c9c62d99e6d48df078c36e35221061cd5f4 Mon Sep 17 00:00:00 2001 From: Roman Fedorov <Roman.Fedorov@constructor.tech> Date: Wed, 24 Jun 2026 16:18:52 +0300 Subject: [PATCH 30/40] test(cli/doc): cover metric-doc lang-override; drop unreachable brace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a doc_rel_path test for the metric-doc <lang>/ override branch (566fb23): a rust-routing principle + an hk metric whose rust/HK.md exists now asserts the override is served (was only exercised on the base/ fallback path). Also fold the inner early-return into an Option chain (map -> filter -> unwrap_or_else) so the closing brace after the return is gone — llvm-cov flagged it as an uncoverable region. Behaviour unchanged; diff-coverage vs origin/main is now clean. --- crates/code-ranker-cli/src/templates.rs | 11 ++++------- crates/code-ranker-cli/src/templates_test.rs | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/crates/code-ranker-cli/src/templates.rs b/crates/code-ranker-cli/src/templates.rs index b42f7da7..78f58af8 100644 --- a/crates/code-ranker-cli/src/templates.rs +++ b/crates/code-ranker-cli/src/templates.rs @@ -56,13 +56,10 @@ fn doc_rel_path(snap: &Snapshot, id: &str) -> Option<String> { // doc override exactly as principle docs do: if the plugin routes its // principle docs to a `<lang>/` folder (via `doc_overrides`) and actually // ships a `<lang>/<doc>.md`, serve that; otherwise the shared `base/`. - if let Some(lang) = override_lang(snap) { - let rel = format!("{lang}/{doc}.md"); - if corpus_doc(&rel).is_some() { - return Some(rel); - } - } - return Some(format!("base/{doc}.md")); + let lang_doc = override_lang(snap) + .map(|lang| format!("{lang}/{doc}.md")) + .filter(|rel| corpus_doc(rel).is_some()); + return Some(lang_doc.unwrap_or_else(|| format!("base/{doc}.md"))); } // Fallback: any base corpus doc addressable by its filename stem // (case-insensitive) — covers docs that are neither a principle nor a metric: diff --git a/crates/code-ranker-cli/src/templates_test.rs b/crates/code-ranker-cli/src/templates_test.rs index 38fa13a9..bd2bf335 100644 --- a/crates/code-ranker-cli/src/templates_test.rs +++ b/crates/code-ranker-cli/src/templates_test.rs @@ -168,6 +168,25 @@ fn resolve_doc_finds_metric_via_remediation_doc_ref() { assert_eq!(doc, corpus_doc("base/HK.md").unwrap()); } +#[test] +fn doc_rel_path_serves_lang_override_for_a_metric_doc() { + // A metric doc (`hk` → `HK`) is routed to the `<lang>/` corpus when the + // plugin's principle docs route there (`override_lang` → "rust") AND that + // language actually ships `rust/HK.md` — the metric-override branch added in + // 566fb23 (templates.rs line 63). Without a rust-routing principle the same + // metric falls back to `base/HK.md` (see the previous test). + let mut attrs = BTreeMap::new(); + attrs.insert( + "hk".to_string(), + metric_spec("Run `code-ranker report --doc HK` and follow its instructions."), + ); + let s = snap( + vec![principle("ADP", "https://x/blob/main/languages/rust/ADP.md")], + attrs, + ); + assert_eq!(doc_rel_path(&s, "HK"), Some("rust/HK.md".to_string())); +} + #[test] fn resolve_doc_unknown_id_errors() { let s = snap( From 5b743dae2a7fbb55230769a832f99cc7f7d217b4 Mon Sep 17 00:00:00 2001 From: Roman Fedorov <Roman.Fedorov@constructor.tech> Date: Wed, 24 Jun 2026 16:22:05 +0300 Subject: [PATCH 31/40] style(cli/doc): rustfmt the new doc_rel_path test --- crates/code-ranker-cli/src/templates_test.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/code-ranker-cli/src/templates_test.rs b/crates/code-ranker-cli/src/templates_test.rs index bd2bf335..8c066771 100644 --- a/crates/code-ranker-cli/src/templates_test.rs +++ b/crates/code-ranker-cli/src/templates_test.rs @@ -181,7 +181,10 @@ fn doc_rel_path_serves_lang_override_for_a_metric_doc() { metric_spec("Run `code-ranker report --doc HK` and follow its instructions."), ); let s = snap( - vec![principle("ADP", "https://x/blob/main/languages/rust/ADP.md")], + vec![principle( + "ADP", + "https://x/blob/main/languages/rust/ADP.md", + )], attrs, ); assert_eq!(doc_rel_path(&s, "HK"), Some("rust/HK.md".to_string())); From 311c9c08adaae93014c3acc50e619e9497141ffc Mon Sep 17 00:00:00 2001 From: Roman Fedorov <Roman.Fedorov@constructor.tech> Date: Wed, 24 Jun 2026 18:39:41 +0300 Subject: [PATCH 32/40] docs: add GitHub App install + website links and the code-ranker badge README: top-row badges for the live code-ranker report, the project website (code-ranker.com) and the GitHub App install; a CI-integration note framing the App as the zero-config per-PR-report option. GitHub Actions Rust guide gets a matching 'prefer zero config -> install the App' cross-reference. Docs-only; no code or format change. --- README.md | 6 ++++++ docs/ci-integration/github/rust/README.md | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/README.md b/README.md index 62091b20..f07c9224 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,15 @@ [![CI](https://github.com/ffedoroff/code-ranker/actions/workflows/ci.yml/badge.svg)](https://github.com/ffedoroff/code-ranker/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/ffedoroff/code-ranker/branch/main/graph/badge.svg)](https://codecov.io/gh/ffedoroff/code-ranker) +[![code-ranker](https://img.shields.io/endpoint?url=https://api.code-ranker.com/badge/ffedoroff/cr-smoke-test.json)](https://reports.code-ranker.com/r/ffedoroff/cr-smoke-test/latest) [![Crates.io](https://img.shields.io/crates/v/code-ranker.svg)](https://crates.io/crates/code-ranker) [![npm](https://img.shields.io/npm/v/code-ranker.svg)](https://www.npmjs.com/package/code-ranker) [![PyPI](https://img.shields.io/pypi/v/code-ranker.svg)](https://pypi.org/project/code-ranker/) [![License](https://img.shields.io/crates/l/code-ranker.svg)](./LICENSE) +[![Website](https://img.shields.io/badge/website-code--ranker.com-1abc9c)](https://code-ranker.com) +[![Install the GitHub App](https://img.shields.io/badge/GitHub%20App-install-2c3e50?logo=github&logoColor=white)](https://github.com/apps/code-ranker-app/installations/new) + Structural-analysis tool for **Rust** (production-ready) plus **Python, TypeScript/JavaScript, Go, C, C++, C# and Markdown** (beta) codebases. Built **AI-agent-friendly first** — finds where a project has structural problems and hands an actionable shortlist to a human or an AI agent for the actual refactor. **👉 Map your codebase's worst structural problems in 30 seconds — [jump to the Rust quick start](#rust-quick-start) and run it on your repo now.** @@ -62,6 +66,8 @@ The linter is the `check` command — exits non-zero on any cycle or threshold v **Add it to your pipeline today** — one `code-ranker check` step stops new cycles and bloat from ever landing. +Prefer zero config? **[Install the GitHub App](https://github.com/apps/code-ranker-app/installations/new)** — it publishes a per-PR HTML structural report on every pull request, no workflow YAML to write. More at **[code-ranker.com](https://code-ranker.com)**. + ## Full CLI Written in Rust — fast, memory-safe, single static-ish binary with **no runtime dependencies** (no Python, no Node, no JVM, no shared libs to install). One file on PATH, done. diff --git a/docs/ci-integration/github/rust/README.md b/docs/ci-integration/github/rust/README.md index 13df246a..9f4f23f0 100644 --- a/docs/ci-integration/github/rust/README.md +++ b/docs/ci-integration/github/rust/README.md @@ -19,6 +19,12 @@ Minimal to start; add the diff wiring once you want per-PR regression diffs. The two reference files are drop-in workflows — copy one into `.github/workflows/` and adjust the `runs-on` / install step. +> **Prefer zero config?** Install the +> **[code-ranker GitHub App](https://github.com/apps/code-ranker-app/installations/new)** +> instead — it runs the analysis and publishes the per-PR HTML report for you, +> with no workflow YAML to maintain. The two modes below are for self-hosted CI +> control. More at [code-ranker.com](https://code-ranker.com). + --- ## Prerequisite: get the binary onto PATH From a2d8f2d8b73133f61324cd95e4ae5855b558cb08 Mon Sep 17 00:00:00 2001 From: Roman Fedorov <Roman.Fedorov@constructor.tech> Date: Thu, 25 Jun 2026 12:17:51 +0300 Subject: [PATCH 33/40] feat(cli/config): accept levels.functions as an inline --config override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--config levels.functions=true` was rejected ("unknown config key") — the inline KEY=VALUE dispatcher had no arm for it, so the functions level could only be enabled via a TOML file. Add the arm (parse_on_off), extend the inline-keys test, and note the inline form in config.md. --- crates/code-ranker-cli/src/config/load/overrides.rs | 1 + crates/code-ranker-cli/src/config/load_test.rs | 2 ++ docs/code-ranker-cli/config.md | 1 + 3 files changed, 4 insertions(+) diff --git a/crates/code-ranker-cli/src/config/load/overrides.rs b/crates/code-ranker-cli/src/config/load/overrides.rs index 520f43e0..67642667 100644 --- a/crates/code-ranker-cli/src/config/load/overrides.rs +++ b/crates/code-ranker-cli/src/config/load/overrides.rs @@ -51,6 +51,7 @@ pub(crate) fn apply_inline_overrides(cfg: &mut Config, entries: &[&str]) -> Resu "output.html.path" => cfg.output.html.path = Some(value.to_string()), "output.json.enabled" => cfg.output.json.enabled = Some(parse_on_off(value)?), "output.html.enabled" => cfg.output.html.enabled = Some(parse_on_off(value)?), + "levels.functions" => cfg.levels.functions = parse_on_off(value)?, _ if key.strip_prefix("rules.cycles.").is_some() => { let kind = key.strip_prefix("rules.cycles.").unwrap(); set_cycle(cfg, kind, parse_cycle_rule(value)?)?; diff --git a/crates/code-ranker-cli/src/config/load_test.rs b/crates/code-ranker-cli/src/config/load_test.rs index 8220d21c..4f8c502d 100644 --- a/crates/code-ranker-cli/src/config/load_test.rs +++ b/crates/code-ranker-cli/src/config/load_test.rs @@ -196,6 +196,7 @@ fn inline_overrides_set_each_key() { "rules.cycles.chain=7", "rules.thresholds.file.loc=800", "rules.thresholds.file.sloc=1200", + "levels.functions=true", ], ) .unwrap(); @@ -209,6 +210,7 @@ fn inline_overrides_set_each_key() { assert_eq!(cfg.rules.cycles.chain, CycleRule::Max(7)); assert_eq!(cfg.rules.thresholds.file.get("loc"), Some(800.0)); assert_eq!(cfg.rules.thresholds.file.get("sloc"), Some(1200.0)); + assert!(cfg.levels.functions); } #[test] diff --git a/docs/code-ranker-cli/config.md b/docs/code-ranker-cli/config.md index a5fcfe9f..5c7a1dbb 100644 --- a/docs/code-ranker-cli/config.md +++ b/docs/code-ranker-cli/config.md @@ -172,6 +172,7 @@ path = "gl-code-quality-report.json" # default if unset: .code-ranker/{ts}-{git [levels] # opt-in extra graph levels beyond `files` # functions = true # emit a `functions` level with per-function metrics +# # (or inline, no file: --config levels.functions=true) [metrics.comment_ratio] # user-defined metric (CEL formula + spec) formula_cel = "sloc > 0.0 ? cloc / sloc * 100.0 : 0.0" From 82ba4f02659d97c69ec29dcbb331d1c0b16f4a9c Mon Sep 17 00:00:00 2001 From: Roman Fedorov <Roman.Fedorov@constructor.tech> Date: Thu, 25 Jun 2026 12:19:01 +0300 Subject: [PATCH 34/40] chore(release): v4.0.0 Bump 4.0.0-alpha.1 -> 4.0.0 (make bump VERSION=4.0.0): workspace Cargo.toml/Cargo.lock and doc version refs. Workspace compiles clean. --- AGENTS.md | 7 ------- CLAUDE.md | 7 ------- Cargo.lock | 10 +++++----- Cargo.toml | 10 +++++----- docs/DESIGN.md | 2 +- 5 files changed, 11 insertions(+), 25 deletions(-) delete mode 100644 AGENTS.md delete mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index c03e8cf1..00000000 --- a/AGENTS.md +++ /dev/null @@ -1,7 +0,0 @@ -<!-- @cf:root-agents --> -```toml -cf-studio-path = ".cf-studio" -``` - -ALWAYS resolve and enforce prerequisites of skills/workflows/commands BEFORE applying user intent. -<!-- /@cf:root-agents --> diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index c03e8cf1..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,7 +0,0 @@ -<!-- @cf:root-agents --> -```toml -cf-studio-path = ".cf-studio" -``` - -ALWAYS resolve and enforce prerequisites of skills/workflows/commands BEFORE applying user intent. -<!-- /@cf:root-agents --> diff --git a/Cargo.lock b/Cargo.lock index ccadcc00..1a6e94ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -268,7 +268,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "code-ranker" -version = "4.0.0-alpha.1" +version = "4.0.0" dependencies = [ "anyhow", "chrono", @@ -286,7 +286,7 @@ dependencies = [ [[package]] name = "code-ranker-graph" -version = "4.0.0-alpha.1" +version = "4.0.0" dependencies = [ "cel", "chrono", @@ -298,7 +298,7 @@ dependencies = [ [[package]] name = "code-ranker-plugin-api" -version = "4.0.0-alpha.1" +version = "4.0.0" dependencies = [ "anyhow", "chrono", @@ -309,7 +309,7 @@ dependencies = [ [[package]] name = "code-ranker-plugins" -version = "4.0.0-alpha.1" +version = "4.0.0" dependencies = [ "anyhow", "cargo_metadata", @@ -336,7 +336,7 @@ dependencies = [ [[package]] name = "code-ranker-viewer" -version = "4.0.0-alpha.1" +version = "4.0.0" dependencies = [ "anyhow", "code-ranker-graph", diff --git a/Cargo.toml b/Cargo.toml index c7fba08c..7cd9c54b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "3" [workspace.package] -version = "4.0.0-alpha.1" +version = "4.0.0" edition = "2024" rust-version = "1.88" license = "Apache-2.0" @@ -12,10 +12,10 @@ keywords = ["dependency-graph", "coupling", "refactoring", "code-quality", "stat categories = ["development-tools", "command-line-utilities"] [workspace.dependencies] -code-ranker-graph = { path = "crates/code-ranker-graph", version = "4.0.0-alpha.1" } -code-ranker-plugin-api = { path = "crates/code-ranker-plugin-api", version = "4.0.0-alpha.1" } -code-ranker-plugins = { path = "crates/code-ranker-plugins", version = "4.0.0-alpha.1" } -code-ranker-viewer = { path = "crates/code-ranker-viewer", version = "4.0.0-alpha.1" } +code-ranker-graph = { path = "crates/code-ranker-graph", version = "4.0.0" } +code-ranker-plugin-api = { path = "crates/code-ranker-plugin-api", version = "4.0.0" } +code-ranker-plugins = { path = "crates/code-ranker-plugins", version = "4.0.0" } +code-ranker-viewer = { path = "crates/code-ranker-viewer", version = "4.0.0" } anyhow = "1.0" cel = "0.13" diff --git a/docs/DESIGN.md b/docs/DESIGN.md index c6d55e9d..fc1f91dd 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -986,7 +986,7 @@ dictionaries with the structural graph and the computed cycles/stats: "workspace": "/Users/alice/projects/code-ranker", "target": "/Users/alice/projects/axum-api", "plugin": "rust", - "versions": { "code-ranker": "4.0.0-alpha.1", "rustc": "1.78.0" }, + "versions": { "code-ranker": "4.0.0", "rustc": "1.78.0" }, "roots": { "registry": "/Users/alice/.cargo/registry/src/index.crates.io-abc123", "target": "/Users/alice/projects/axum-api" From 5c4381fc34afb3e9fc428d91eb6aa6c9336a4074 Mon Sep 17 00:00:00 2001 From: Roman Fedorov <Roman.Fedorov@constructor.tech> Date: Thu, 25 Jun 2026 12:44:45 +0300 Subject: [PATCH 35/40] chore(release): sync 4.0.0 version refs and README status Drop the leftover 4.0.0-alpha.1 strings the bump didn't touch: the 9 e2e golden snapshots' embedded code-ranker version, the docs/versions.md examples, and the prompting-self-improve example rows. README status updated from 'pre-alpha' to 4.0.0 (Rust production-ready, other languages beta). e2e + markdownlint green. --- README.md | 2 +- contrib/prompting-self-improve.md | 4 ++-- .../src/languages/c/tests/sample/code-ranker-report.json | 2 +- .../src/languages/cpp/tests/sample/code-ranker-report.json | 2 +- .../src/languages/csharp/tests/sample/code-ranker-report.json | 2 +- .../src/languages/go/tests/sample/code-ranker-report.json | 2 +- .../languages/javascript/tests/sample/code-ranker-report.json | 2 +- .../languages/markdown/tests/sample/code-ranker-report.json | 2 +- .../src/languages/python/tests/sample/code-ranker-report.json | 2 +- .../src/languages/rust/tests/sample/code-ranker-report.json | 2 +- .../languages/typescript/tests/sample/code-ranker-report.json | 2 +- docs/versions.md | 4 ++-- 12 files changed, 14 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index f07c9224..aa6f20d1 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Structural-analysis tool for **Rust** (production-ready) plus **Python, TypeScri **👉 Map your codebase's worst structural problems in 30 seconds — [jump to the Rust quick start](#rust-quick-start) and run it on your repo now.** -**Status:** pre-alpha. APIs and output shapes may change without notice. +**Status:** 4.0.0 — the Rust analyzer is production-ready; the other languages are beta, so their output shapes may still change. ## Rust quick start diff --git a/contrib/prompting-self-improve.md b/contrib/prompting-self-improve.md index 5986b453..c536d58a 100644 --- a/contrib/prompting-self-improve.md +++ b/contrib/prompting-self-improve.md @@ -512,8 +512,8 @@ Track one row per run so the sweep is auditable: | date | cr version+commit | PROJECT | FOCUS | MODEL | iter | branch | verdict (Δ) | tests | quality 1–5 | tokens | time (s) | notes / failure class | |------|-------------------|---------|-------|-------|------|--------|-------------|-------|-------------|--------|----------|----------------------| -| … | 4.0.0-alpha.1 @abc123 | … | cycle | opus | 1 | opus-cycle-1 | improved (−2 cycles) | pass | 5 | 49.7k | 196 | reference | -| … | 4.0.0-alpha.1 @abc123 | … | cycle | sonnet | 1 | sonnet-cycle-1 | neutral (0) | pass | 2 | 88k | 310 | skipped `--doc`, hacked one edge | +| … | 4.0.0 @abc123 | … | cycle | opus | 1 | opus-cycle-1 | improved (−2 cycles) | pass | 5 | 49.7k | 196 | reference | +| … | 4.0.0 @abc123 | … | cycle | sonnet | 1 | sonnet-cycle-1 | neutral (0) | pass | 2 | 88k | 310 | skipped `--doc`, hacked one edge | `tokens` and `time (s)` are the cost axis at a glance (full breakdown — `tool_calls`, `commands`, `input_tokens`, `output_tokens`, `wall_s` — lives in 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 24d1ba7c..dc9baf60 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 @@ -769,7 +769,7 @@ } ], "versions": { - "code-ranker": "4.0.0-alpha.1" + "code-ranker": "4.0.0" }, "workspace": "/home/user/code-ranker" } 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 9030fda0..a547fc1d 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 @@ -779,7 +779,7 @@ } ], "versions": { - "code-ranker": "4.0.0-alpha.1" + "code-ranker": "4.0.0" }, "workspace": "/home/user/code-ranker" } 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 0eadb3ee..8a78fd2a 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 @@ -715,7 +715,7 @@ } ], "versions": { - "code-ranker": "4.0.0-alpha.1" + "code-ranker": "4.0.0" }, "workspace": "/home/user/code-ranker" } 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 de39ec50..f16a2ba7 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 @@ -717,7 +717,7 @@ } ], "versions": { - "code-ranker": "4.0.0-alpha.1" + "code-ranker": "4.0.0" }, "workspace": "/home/user/code-ranker" } diff --git a/crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker-report.json b/crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker-report.json index a01c4018..b57e4964 100644 --- a/crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker-report.json +++ b/crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker-report.json @@ -958,7 +958,7 @@ } ], "versions": { - "code-ranker": "4.0.0-alpha.1" + "code-ranker": "4.0.0" }, "workspace": "/home/user/code-ranker" } diff --git a/crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker-report.json b/crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker-report.json index 44f9bda4..ba5d305a 100644 --- a/crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker-report.json +++ b/crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker-report.json @@ -264,7 +264,7 @@ } ], "versions": { - "code-ranker": "4.0.0-alpha.1" + "code-ranker": "4.0.0" }, "workspace": "/home/user/code-ranker" } 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 6bbcf9b5..d42c711b 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 @@ -1067,7 +1067,7 @@ } ], "versions": { - "code-ranker": "4.0.0-alpha.1" + "code-ranker": "4.0.0" }, "workspace": "/home/user/code-ranker" } 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 af2ba346..333c79b3 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 @@ -1756,7 +1756,7 @@ } ], "versions": { - "code-ranker": "4.0.0-alpha.1", + "code-ranker": "4.0.0", "rustc": "1.96.0" }, "workspace": "/home/user/code-ranker" diff --git a/crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker-report.json b/crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker-report.json index c5e7afe9..c1c110ad 100644 --- a/crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker-report.json +++ b/crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker-report.json @@ -1025,7 +1025,7 @@ } ], "versions": { - "code-ranker": "4.0.0-alpha.1" + "code-ranker": "4.0.0" }, "workspace": "/home/user/code-ranker" } diff --git a/docs/versions.md b/docs/versions.md index 8e66441e..c8496830 100644 --- a/docs/versions.md +++ b/docs/versions.md @@ -7,7 +7,7 @@ does **not** force a config migration unless the config format itself changed. | # | Surface | Constant | Lives in | Current | |---|---------|----------|----------|---------| -| 1 | **app** — the release | `[workspace.package] version` (`env!("CARGO_PKG_VERSION")`) | root `Cargo.toml` | `4.0.0-alpha.1` | +| 1 | **app** — the release | `[workspace.package] version` (`env!("CARGO_PKG_VERSION")`) | root `Cargo.toml` | `4.0.0` | | 2 | **config + CLI** — the user-facing input interface | `CONFIG_VERSION` | `crates/code-ranker-graph/src/version.rs` | `4.0` | | 3 | **JSON snapshot + viewer** — the data format and its consumer | `SCHEMA_VERSION` | `crates/code-ranker-graph/src/version.rs` | `4.0` | @@ -19,7 +19,7 @@ lives **only** at its constant; every consumer imports it, never hardcodes it ## 1. app version -Plain SemVer of the release (`4.0.0-alpha.1`). Bumped with `make bump VERSION=…`, +Plain SemVer of the release (`4.0.0`). Bumped with `make bump VERSION=…`, which rewrites `Cargo.toml`, `README.md` and the `code-ranker`/`--version` doc mentions. Every normal release bumps it; it does **not** imply a format change. From 47349dafea5824fd05f1e2da75633fe1e8c29d75 Mon Sep 17 00:00:00 2001 From: Roman Fedorov <Roman.Fedorov@constructor.tech> Date: Thu, 25 Jun 2026 19:52:55 +0300 Subject: [PATCH 36/40] feat(cli): add --output.mode verbosity flag; scorecard tiers as words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate the stderr diagnostic stream behind a global --output.mode <quiet|summary|verbose> (default summary): quiet = errors only; summary = warnings, written-artifact paths, and the closing '✓ … — <time>' line; verbose = also the ▶/config: startup lines and every external command's ↳ timing. The level is a process-wide switch in code-ranker-plugin-api::log so CLI stages and plugins share one knob; stdout artifacts are unaffected. Render the scorecard's advisory tiers as words — WARN/INFO column headers and warn/info row tags — instead of the ⚠/ⓘ glyphs. Docs synced: code-ranker-cli/{CLI,PRD,DESIGN,USE-CASES}.md and root DESIGN.md. No format-version bump: CONFIG_VERSION already moved to 4.0 on this branch and these are additive (minor), so per docs/versions.md they ride it. --- crates/code-ranker-cli/src/cli.rs | 28 +++++++++ crates/code-ranker-cli/src/config/load.rs | 4 +- crates/code-ranker-cli/src/export.rs | 2 +- crates/code-ranker-cli/src/logger.rs | 15 ++++- crates/code-ranker-cli/src/main.rs | 21 +++++-- crates/code-ranker-cli/src/pipeline.rs | 4 +- .../src/recommend/scorecard.rs | 20 +++---- crates/code-ranker-cli/src/recommend_test.rs | 8 +-- crates/code-ranker-cli/src/report.rs | 2 +- crates/code-ranker-plugin-api/src/log.rs | 58 ++++++++++++++++++- docs/DESIGN.md | 9 ++- docs/code-ranker-cli/CLI.md | 32 +++++----- docs/code-ranker-cli/DESIGN.md | 5 +- docs/code-ranker-cli/PRD.md | 6 +- docs/code-ranker-cli/USE-CASES.md | 6 +- 15 files changed, 169 insertions(+), 51 deletions(-) diff --git a/crates/code-ranker-cli/src/cli.rs b/crates/code-ranker-cli/src/cli.rs index 09a48cb4..73ac4f92 100644 --- a/crates/code-ranker-cli/src/cli.rs +++ b/crates/code-ranker-cli/src/cli.rs @@ -11,10 +11,38 @@ use std::path::PathBuf; about = "Pluggable multi-language structural analysis platform" )] pub(crate) struct Cli { + /// Verbosity of the stderr diagnostic stream (machine output/artifacts always + /// go to stdout/files, untouched by this). `quiet` = errors only; `summary` + /// (default) = errors, warnings, written-artifact paths, and the closing + /// `✓ … — <time>` line; `verbose` = also the `▶`/`config:` startup lines and + /// every external command's duration (`↳ cargo metadata — 0.399s`). Global: + /// accepted before or after the subcommand. + #[arg( + long = "output.mode", + value_enum, + default_value_t = OutputMode::Summary, + global = true, + value_name = "quiet|summary|verbose" + )] + pub(crate) output_mode: OutputMode, + #[command(subcommand)] pub(crate) command: Command, } +/// Verbosity of the stderr diagnostic stream. Mapped to `log::{QUIET,SUMMARY,VERBOSE}` +/// in `main` and applied process-wide before the first line is emitted. +#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum, Default)] +pub(crate) enum OutputMode { + /// Errors only — stderr is otherwise silent. + Quiet, + /// Errors, warnings, written-artifact paths, and the closing `✓` line. + #[default] + Summary, + /// Everything, including the `▶`/`config:` startup lines and per-tool `↳` timings. + Verbose, +} + /// Diagnostics format for `check`. #[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum, Default)] pub(crate) enum OutputFormat { diff --git a/crates/code-ranker-cli/src/config/load.rs b/crates/code-ranker-cli/src/config/load.rs index 1b4327f7..9645d65a 100644 --- a/crates/code-ranker-cli/src/config/load.rs +++ b/crates/code-ranker-cli/src/config/load.rs @@ -62,8 +62,8 @@ pub fn load( // files, auto-discovery of `code-ranker.toml` is skipped. let (layers, source_file) = discover_user_tables(workspace, &explicit)?; match &source_file { - Some(p) => log::line(&format!("config: {p}")), - None => log::line("config: built-in defaults (no config file found)"), + Some(p) => log::verbose(&format!("config: {p}")), + None => log::verbose("config: built-in defaults (no config file found)"), } let merged = layers.into_iter().fold(builtin_table(), deep_merge); let mut config: Config = merged diff --git a/crates/code-ranker-cli/src/export.rs b/crates/code-ranker-cli/src/export.rs index 472a2a6d..d5eb71c0 100644 --- a/crates/code-ranker-cli/src/export.rs +++ b/crates/code-ranker-cli/src/export.rs @@ -69,7 +69,7 @@ pub(crate) fn export_full_config(args: &AnalyzeArgs, out: &Path) -> Result<()> { std::fs::write(out, format!("{header}{body}")) .with_context(|| format!("writing {}", out.display()))?; - logger::info(&format!( + logger::summary(&format!( "✓ wrote full config for plugin `{plugin_name}` to {}", out.display() )); diff --git a/crates/code-ranker-cli/src/logger.rs b/crates/code-ranker-cli/src/logger.rs index 1d5f2a54..2de1fd83 100644 --- a/crates/code-ranker-cli/src/logger.rs +++ b/crates/code-ranker-cli/src/logger.rs @@ -1,10 +1,21 @@ use code_ranker_plugin_api::log; use std::time::Instant; -pub fn info(msg: &str) { +/// Always shown, even at `--output.mode quiet` (errors). +pub fn error(msg: &str) { log::line(msg); } +/// Shown at `summary`+ : warnings and written-artifact confirmations. +pub fn summary(msg: &str) { + log::summary(msg); +} + +/// Shown only at `--output.mode verbose` : the `▶` command echo and `config:` line. +pub fn verbose(msg: &str) { + log::verbose(msg); +} + pub struct Timer { label: String, start: Instant, @@ -20,7 +31,7 @@ impl Timer { pub fn finish_with(self, extra: &str) -> u64 { let elapsed = self.start.elapsed(); - log::line(&format!( + log::summary(&format!( "✓ {} — {}{}", self.label, log::secs(elapsed), diff --git a/crates/code-ranker-cli/src/main.rs b/crates/code-ranker-cli/src/main.rs index 1d6d663a..289acc50 100644 --- a/crates/code-ranker-cli/src/main.rs +++ b/crates/code-ranker-cli/src/main.rs @@ -24,19 +24,28 @@ mod templates; use anyhow::Result; use clap::Parser; +use code_ranker_plugin_api::log; -use cli::{Cli, Command}; +use cli::{Cli, Command, OutputMode}; fn main() -> Result<()> { let cli = Cli::parse(); + // Apply the verbosity before emitting anything: every later line (here, in the + // stages, and in the plugins) reads this one switch. `--output.mode` is global, + // so it is honoured wherever it appears on the command line. + log::set_level(match cli.output_mode { + OutputMode::Quiet => log::QUIET, + OutputMode::Summary => log::SUMMARY, + OutputMode::Verbose => log::VERBOSE, + }); let cmd = format!( "code-ranker {}", std::env::args().skip(1).collect::<Vec<_>>().join(" ") ); - // Startup line: the exact command this run was invoked with. The config it - // resolved is logged next, by `config::load`. The matching `✓ … — <time>` - // finish line is emitted by this timer. - logger::info(&format!("▶ {cmd}")); + // Startup line (verbose only): the exact command this run was invoked with. The + // config it resolved is logged next, by `config::load`. The matching summary-tier + // `✓ … — <time>` finish line is emitted by this timer. + logger::verbose(&format!("▶ {cmd}")); let t = logger::Timer::start(&cmd); let res = match cli.command { Command::Check { @@ -129,7 +138,7 @@ fn main() -> Result<()> { Ok(_) => { t.finish(); } - Err(e) => logger::info(&format!("error: {e:#}")), + Err(e) => logger::error(&format!("error: {e:#}")), } res } diff --git a/crates/code-ranker-cli/src/pipeline.rs b/crates/code-ranker-cli/src/pipeline.rs index 964519b6..77519186 100644 --- a/crates/code-ranker-cli/src/pipeline.rs +++ b/crates/code-ranker-cli/src/pipeline.rs @@ -251,7 +251,7 @@ pub(crate) fn analyze_directory( .any(|n| n.kind != "external" && n.attrs.contains_key(key)), }; if !present { - logger::info(&format!( + logger::summary(&format!( "⚠ metric `{key}` produced no value on any node — check its formula \ (a misspelled input key?) or whether it is always at its no-signal value", )); @@ -401,7 +401,7 @@ fn gate_thresholds( let info = match declared_info { Some(i) if i < warning => i, Some(i) => { - logger::info(&format!( + logger::summary(&format!( "⚠ `[metrics.{key}]` info ({i}) ≥ gate threshold ({warning}); \ dropping the info tier for `{key}` (the gate wins)", )); diff --git a/crates/code-ranker-cli/src/recommend/scorecard.rs b/crates/code-ranker-cli/src/recommend/scorecard.rs index aeda2c70..06a420ba 100644 --- a/crates/code-ranker-cli/src/recommend/scorecard.rs +++ b/crates/code-ranker-cli/src/recommend/scorecard.rs @@ -30,7 +30,7 @@ struct Row { /// One row of the worst-modules list. struct ModRow { - warning_icon: bool, + is_warning: bool, path: String, head: String, rest: Vec<String>, @@ -255,10 +255,10 @@ fn render_principle_table(out: &mut String, rows: &[Row], want_warning: bool, wa }; let mut header = format!("{:<id_w$} {:<name_w$}", "PRESET", "PRINCIPLE"); if want_warning { - header.push_str(" ⚠"); + header.push_str(" WARN"); } if want_info { - header.push_str(" ⓘ"); + header.push_str(" INFO"); } header.push_str(" TOP MODULE"); out.push_str(&header); @@ -266,10 +266,10 @@ fn render_principle_table(out: &mut String, rows: &[Row], want_warning: bool, wa for r in rows { let mut line = format!("{:<id_w$} {:<name_w$}", r.id, clip(&r.name, name_w)); if want_warning { - line.push_str(&format!(" {:>1}", r.warn)); + line.push_str(&format!(" {:>4}", r.warn)); } if want_info { - line.push_str(&format!(" {:>1}", r.info)); + line.push_str(&format!(" {:>4}", r.info)); } line.push_str(&format!(" {}", r.top)); out.push_str(&line); @@ -316,7 +316,7 @@ fn cycle_mod_rows(out: &mut String, level: &LevelGraph, top: Option<usize>) -> V for (g, members) in &groups { for n in members { mod_rows.push(ModRow { - warning_icon: true, + is_warning: true, path: clean_path(&n.id), head: g.kind.clone(), rest: Vec::new(), @@ -350,7 +350,7 @@ fn metric_mod_rows( _ => attr_short(level, m).to_string(), }; ModRow { - warning_icon: true, + is_warning: true, path: clean_path(&n.id), head, rest: Vec::new(), @@ -410,7 +410,7 @@ fn breach_row(level: &LevelGraph, n: &Node, breaches: &[Breach]) -> ModRow { .map(|b| breach_label(level, &b.metric, None)) .collect(); ModRow { - warning_icon: n_warn > 0, + is_warning: n_warn > 0, path: clean_path(&n.id), head, rest, @@ -436,8 +436,8 @@ fn breach_label(level: &LevelGraph, metric: &str, value: Option<f64>) -> 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 icon = if r.warning_icon { "⚠" } else { "ⓘ" }; - let mut line = format!("{:>2} {} {:<path_w$} {}", i + 1, icon, r.path, r.head); + let tier = if r.is_warning { "warn" } else { "info" }; + let mut line = format!("{:>2} {:<4} {:<path_w$} {}", i + 1, tier, r.path, r.head); if !r.rest.is_empty() { line.push_str(&format!(" +{}", r.rest.join(", "))); } diff --git a/crates/code-ranker-cli/src/recommend_test.rs b/crates/code-ranker-cli/src/recommend_test.rs index bb293a6b..e0288a08 100644 --- a/crates/code-ranker-cli/src/recommend_test.rs +++ b/crates/code-ranker-cli/src/recommend_test.rs @@ -528,8 +528,8 @@ fn resolve_focus_picks_metric_or_principle() { } /// Info-tier breaches: a node over the info line (but under warning) is shown -/// with the ⓘ icon, and a worse metric pushes a co-occurring cycle into the -/// `+rest` list (the non-cycle-worst path). +/// with the `info` tier label, and a worse metric pushes a co-occurring cycle +/// into the `+rest` list (the non-cycle-worst path). #[test] fn scorecard_info_tier_and_cycle_in_rest() { let level = level_with(vec![ @@ -555,8 +555,8 @@ fn scorecard_info_tier_and_cycle_in_rest() { ) .unwrap(); assert!( - sc.contains("info.rs") && sc.contains("ⓘ"), - "info icon: {sc}" + sc.contains("info.rs") && sc.contains("info "), + "info-tier label on the info-only module: {sc}" ); assert!( sc.contains("hot.rs") && sc.contains("+cycle"), diff --git a/crates/code-ranker-cli/src/report.rs b/crates/code-ranker-cli/src/report.rs index 5a2c0cd9..7f6145ac 100644 --- a/crates/code-ranker-cli/src/report.rs +++ b/crates/code-ranker-cli/src/report.rs @@ -397,7 +397,7 @@ fn write_artifact(dest: &str, content: &str, kind: &str) -> Result<()> { } std::fs::write(path, content) .with_context(|| format!("writing {kind} to {}", path.display()))?; - logger::info(&format!("{kind}-report={}", path.display())); + logger::summary(&format!("{kind}-report={}", path.display())); Ok(()) } diff --git a/crates/code-ranker-plugin-api/src/log.rs b/crates/code-ranker-plugin-api/src/log.rs index e149ca3c..6a56ad18 100644 --- a/crates/code-ranker-plugin-api/src/log.rs +++ b/crates/code-ranker-plugin-api/src/log.rs @@ -5,10 +5,41 @@ //! one consistent line format. All output goes to **stderr** (machine output and //! artifacts go to stdout/files), prefixed with a local `HH:MM:SS.mmm` stamp. //! Durations are printed to **millisecond precision** (`0.231s`). +//! +//! How loud that stream is is governed by a single process-wide [verbosity +//! level](set_level), set once at startup from `--output.mode`. The level lives +//! here (not in the CLI) because the lines it gates are emitted from both the CLI +//! stages and the plugins — they share one switch. Emitters come in three tiers: +//! [`line`] always prints (errors); [`summary`] prints at `SUMMARY`+ (the closing +//! `✓` line, warnings, written-artifact paths); [`verbose`]/[`subcmd`] print only +//! at `VERBOSE` (the `▶`/`config:` startup lines and every external-tool timing). use chrono::Local; +use std::sync::atomic::{AtomicU8, Ordering}; use std::time::{Duration, Instant}; +/// Silence everything but errors. +pub const QUIET: u8 = 0; +/// Default: errors, warnings, written-artifact paths, and the closing `✓` line. +pub const SUMMARY: u8 = 1; +/// Everything, including the `▶`/`config:` startup lines and per-tool `↳` timings. +pub const VERBOSE: u8 = 2; + +// Defaults to SUMMARY so a process that never calls `set_level` (e.g. a test or a +// plugin exercised in isolation) still behaves like the documented default. +static LEVEL: AtomicU8 = AtomicU8::new(SUMMARY); + +/// Set the process-wide verbosity. Called once from `main` after arg parsing, +/// before the first line is emitted. Takes one of [`QUIET`]/[`SUMMARY`]/[`VERBOSE`]. +pub fn set_level(level: u8) { + LEVEL.store(level, Ordering::Relaxed); +} + +/// The current verbosity level. +pub fn level() -> u8 { + LEVEL.load(Ordering::Relaxed) +} + /// Local wall-clock stamp, `HH:MM:SS.mmm`. pub fn stamp() -> String { Local::now().format("%H:%M:%S%.3f").to_string() @@ -20,16 +51,37 @@ pub fn secs(dur: Duration) -> String { format!("{:.3}s", dur.as_secs_f64()) } -/// Emit one stamped line to stderr: `[HH:MM:SS.mmm] <msg>`. +/// Emit one stamped line to stderr unconditionally: `[HH:MM:SS.mmm] <msg>`. +/// Reserved for messages that must show at every level (errors). Tier-gated +/// callers use [`summary`] / [`verbose`] instead. pub fn line(msg: &str) { eprintln!("[{}] {}", stamp(), msg); } +/// Emit a line only at [`SUMMARY`] or louder: the closing `✓` line, warnings, +/// and written-artifact paths — the minimal "what happened" trace. +pub fn summary(msg: &str) { + if level() >= SUMMARY { + line(msg); + } +} + +/// Emit a line only at [`VERBOSE`]: the `▶`/`config:` startup lines — diagnostic +/// detail that would clutter the default stream. +pub fn verbose(msg: &str) { + if level() >= VERBOSE { + line(msg); + } +} + /// Log a completed internal sub-command (an external tool code-ranker shelled out /// to) with its duration: `[HH:MM:SS.mmm] ↳ <label> — 0.231s`. The `↳` marks it -/// as a nested step under the current stage. +/// as a nested step under the current stage. Shown only at [`VERBOSE`] — the work +/// still runs at every level (see [`timed`]); only the line is gated. pub fn subcmd(label: &str, dur: Duration) { - line(&format!("↳ {label} — {}", secs(dur))); + if level() >= VERBOSE { + line(&format!("↳ {label} — {}", secs(dur))); + } } /// Time `f`, log it as a sub-command (see [`subcmd`]), and return its value. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index fc1f91dd..c367fd9b 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -817,7 +817,14 @@ See [§3.7 Plugin System](#37-plugin-system). shell-outs (`cargo metadata`, `rustc`) emit one consistent `[HH:MM:SS.mmm]` format. `log::timed(label, f)` wraps every external invocation and prints its duration to millisecond precision; `code-ranker-cli`'s `logger` delegates its - formatting here. + formatting here. How loud the stream is is a single process-wide verbosity level + (`log::set_level`, one of `QUIET`/`SUMMARY`/`VERBOSE`) set once in `main` from the + global `--output.mode` flag — it lives in the foundation crate because the gated + lines come from both the CLI stages and the plugins, which share one switch. + Emitters tier accordingly: `line` always prints (errors), `summary` prints at + `SUMMARY`+ (the closing `✓` line, warnings, written-artifact paths), and + `verbose`/`subcmd` print only at `VERBOSE` (the `▶`/`config:` startup lines and + every `↳` shell-out timing). - The Rust plugin's module→file collapse lives in `code-ranker-plugins/src/rust/mod.rs`. - `code-ranker-cli` orchestrates: it dispatches the language plugins (through the trait) and hands the snapshot to `code-ranker-viewer` for rendering. diff --git a/docs/code-ranker-cli/CLI.md b/docs/code-ranker-cli/CLI.md index af22b726..ca54b1ce 100644 --- a/docs/code-ranker-cli/CLI.md +++ b/docs/code-ranker-cli/CLI.md @@ -35,21 +35,25 @@ or, for the overview, `code-ranker ai`; see [templates.md](../templates.md).) ## Global options -`code-ranker` takes no global flags of its own beyond the clap built-ins: +`code-ranker` takes these global flags (accepted before or after the subcommand); +all other flags are per-command and must follow the command name: | Flag | Meaning | |---|---| | `-h, --help` | Print help — top-level, or per-command with `code-ranker <cmd> --help`. | | `-V, --version` | Print the version. | +| `--output.mode <quiet\|summary\|verbose>` | Verbosity of the **stderr** diagnostic stream (default `summary`). Machine output and artifacts on stdout/files are unaffected. See below. | Progress and timing lines are written to **stderr**, each stamped `[HH:MM:SS.mmm]`; diagnostics and machine output go to **stdout** or files, so the two streams never mix. -A run opens with a `▶ <command>` startup line and the resolved `config:` path, logs -every external tool it shells out to with its duration to millisecond precision -(`↳ cargo metadata --offline — 28.500s`, `↳ git status --porcelain — 0.017s`, -`rustc …`), and closes with a `✓ <command> — <time>` line. The sub-command lines make -the cost of a cold cargo cache visible at a glance. All other flags are per-command and -must follow the command name. +`--output.mode` controls how much of that stderr stream is emitted — it never changes +what lands on stdout: + +| `--output.mode` | What stderr shows | +|---|---| +| `quiet` | Errors only (`error: …`); stderr is otherwise silent. Handy for scripts/CI that want a clean stream. | +| `summary` *(default)* | Errors, config-sanity warnings (`⚠ …`), written-artifact paths (`html-report=…`), and the closing `✓ <command> — <time>` line — the command name and total time, nothing more. | +| `verbose` | Everything: the `▶ <command>` startup line, the resolved `config:` path, and every external tool it shells out to with its duration to millisecond precision (`↳ cargo metadata --offline — 28.500s`, `↳ git status --porcelain — 0.017s`, `↳ rustc …`). The `↳` lines make the cost of a cold cargo cache visible at a glance. | ## Input: code or snapshot @@ -531,15 +535,15 @@ code-ranker report . --output.scorecard --focus sloc # narrow to one metric ```text scorecard (rust, 142 files) -PRESET PRINCIPLE ⚠ ⓘ TOP MODULE -ADP Acyclic Dependencies 2 2 a.rs ↔ b.rs -SRP Single Responsibility 5 18 cli/main.rs (sloc 1832) -CPX Reduce Complexity 3 11 cli/main.rs (cog 67) +PRESET PRINCIPLE WARN INFO TOP MODULE +ADP Acyclic Dependencies 2 2 a.rs ↔ b.rs +SRP Single Responsibility 5 18 cli/main.rs (sloc 1832) +CPX Reduce Complexity 3 11 cli/main.rs (cog 67) WORST MODULES - 1 ⚠ cli/main.rs hk 4.2M +sloc, fan_out, cycle - 2 ⚠ snapshot.rs sloc 1.8K +hk - 3 ⓘ plugin/rust.rs fan_out 14 + 1 warn cli/main.rs hk 4.2M +sloc, fan_out, cycle + 2 warn snapshot.rs sloc 1.8K +hk + 3 info plugin/rust.rs fan_out 14 → code-ranker report . --output.prompt.path=… --top 1 ``` diff --git a/docs/code-ranker-cli/DESIGN.md b/docs/code-ranker-cli/DESIGN.md index 46c514b2..5eebd5a9 100644 --- a/docs/code-ranker-cli/DESIGN.md +++ b/docs/code-ranker-cli/DESIGN.md @@ -50,7 +50,10 @@ playbook command), and the `config/` module (`model` / `load` / `ignore` / `rules` / `violations`, re-exported through its `mod.rs` facade). `pipeline.rs` concentrates the high fan-out orchestration behind a single caller (`analyze_input`), keeping every file's Henry-Kafura HK -low. +low. Before dispatching, `main()` applies the global `--output.mode` +(`quiet`/`summary`/`verbose`) to the shared stderr log's process-wide verbosity +level (`code-ranker-plugin-api::log`), so every later line — from the stages and +the plugins alike — honours it; stdout artifacts are never affected. The shared analysis core (`analyze_input`, used by both `check` and `report`) either reads an embedded snapshot (`.json`/`.html` input — `analyze_from_snapshot`, diff --git a/docs/code-ranker-cli/PRD.md b/docs/code-ranker-cli/PRD.md index e46c2d3b..03094f86 100644 --- a/docs/code-ranker-cli/PRD.md +++ b/docs/code-ranker-cli/PRD.md @@ -369,7 +369,11 @@ only on new violations) whose verdict is machine-readable with `--output-format json`. Global options accepted by every command: `--config <PATH | KEY=VALUE>` -(repeatable; inline wins), `-h/--help`, `-V/--version`. +(repeatable; inline wins), `--output.mode <quiet|summary|verbose>` (verbosity of +the **stderr** diagnostic stream; default `summary` — errors, warnings, +written-artifact paths and the closing `✓ … — <time>` line; `quiet` = errors only; +`verbose` = also the `▶`/`config:` startup lines and every external command's +`↳` timing — stdout/artifacts are unaffected), `-h/--help`, `-V/--version`. **Exit codes**: 0 = `check` passed (or `--exit-zero`), `report` completed; non-zero = generic failure, or `check` found a violation; diff --git a/docs/code-ranker-cli/USE-CASES.md b/docs/code-ranker-cli/USE-CASES.md index ac5099b4..edbfe4ea 100644 --- a/docs/code-ranker-cli/USE-CASES.md +++ b/docs/code-ranker-cli/USE-CASES.md @@ -9,9 +9,9 @@ Two commands underlie everything: `rules.cycles`), prints diagnostics, and **exits non-zero** on a violation. Writes no files. Thresholds come from `code-ranker.toml` (or `--threshold` overrides). - **`report`** — produces **artifacts** (JSON snapshot, HTML viewer) and the **advisory** - outputs (`scorecard` triage, `prompt` for an AI). The advisory tiers (⚠ warning / ⓘ info) - are driven by the **same `[rules.thresholds.file]` limits the gate enforces** — ⚠ is the - gate line, ⓘ an optional softer line below it — so the report shows what fails (or is + outputs (`scorecard` triage, `prompt` for an AI). The advisory tiers (`warn` / `info`) + are driven by the **same `[rules.thresholds.file]` limits the gate enforces** — `warn` is the + gate line, `info` an optional softer line below it — so the report shows what fails (or is about to fail) `check`. Always exits `0`. `[input]` is polymorphic: a directory is analyzed; a `.json`/`.html` snapshot is read back From 949310cb8ca585ca845a05622b1f5fe15e226b09 Mon Sep 17 00:00:00 2001 From: Roman Fedorov <Roman.Fedorov@constructor.tech> Date: Thu, 25 Jun 2026 21:31:32 +0300 Subject: [PATCH 37/40] docs(metrics): document spaces/branches/span_sloc/cycle; fix MI size term MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit base/metrics.md is the normative metric spec but omitted four emitted node attributes — spaces/branches (cyclomatic's summands), span_sloc (the MI size input), and the categorical cycle tag — and the eta/N Halstead base counts were not named by key. Document all of them, and correct the MI formula to use span_sloc (the unit line span the engine actually feeds, per builtin.toml). --- languages/base/metrics.md | 40 +++++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/languages/base/metrics.md b/languages/base/metrics.md index 49ac2339..588f0341 100644 --- a/languages/base/metrics.md +++ b/languages/base/metrics.md @@ -163,6 +163,13 @@ functions (a pure type or declaration block) is left with only the file unit's vacuous `1`, so the metric is **omitted** (`omit_at` = 1) rather than reported as a bare `1`. +The engine emits the two summands of this sum as metrics in their own right, so +the viewer can render the derivation with the node's real numbers: +**`spaces`** — the *unit count*, the source file (1) plus each function / impl / +trait / closure space (the base paths); and **`branches`** — the *decision +points* (`if` / `for` / `while` / `loop` / match arm / `?` / `&&` / `||`). They +combine as **`cyclomatic = spaces + branches`**. + > **Sources:** McCabe, "A Complexity Measure" (1976); the multi-component form > `V(G) = E − N + 2P` and its equality with the per-function sum are described in > [Wikipedia: Cyclomatic complexity](https://en.wikipedia.org/wiki/Cyclomatic_complexity); @@ -229,8 +236,11 @@ From the two maps come four raw counts: | **η₂** | distinct operands | `operands.len()` | | **N₂** | total operand occurrences | sum of operand counts | -Everything else is arithmetic on those four. Worked on the expression -`x = a + a * 2` (illustrative tokenization): +These four raw counts are themselves emitted as node attributes — **`eta1`** +(η₁), **`n1`** (N₁), **`eta2`** (η₂), **`n2`** (N₂) — so the viewer can show each +derived Halstead formula filled in with this unit's actual numbers. Everything +else is arithmetic on those four. Worked on the expression `x = a + a * 2` +(illustrative tokenization): ``` operators: =, +, * → η₁ = 3, N₁ = 3 (each used once) @@ -258,14 +268,18 @@ A single 0–100 score (higher = more maintainable) folding volume, branching, and size together: ``` -mi = 171 − 5.2·ln(volume) − 0.23·cyclomatic − 16.2·ln(sloc) -mi_sei = 171 − 5.2·log₂(volume) − 0.23·cyclomatic − 16.2·log₂(sloc) - + 50·sin(√(2.4 × comment_ratio)) comment_ratio = cloc ÷ sloc +mi = 171 − 5.2·ln(volume) − 0.23·cyclomatic − 16.2·ln(span_sloc) +mi_sei = 171 − 5.2·log₂(volume) − 0.23·cyclomatic − 16.2·log₂(span_sloc) + + 50·sin(√(2.4 × comment_ratio)) comment_ratio = cloc ÷ span_sloc ``` -`mi` punishes big (`sloc`), complex (`cyclomatic`), and dense (`volume`) code. -`mi_sei` is the SEI variant: same skeleton on a log₂ basis, plus a bonus for -comment density — well-documented code scores higher. +The size input is **`span_sloc`** — the unit's *line span* (`end_row − +start_row`), emitted as its own metric — **not** `sloc`. It is the analyzer's +size proxy for the classic MI's `LOC` term, kept distinct so `mi` and `mi_sei` +share one consistent size number. `mi` punishes big (`span_sloc`), complex +(`cyclomatic`), and dense (`volume`) code. `mi_sei` is the SEI variant: same +skeleton on a log₂ basis, plus a bonus for comment density — well-documented code +scores higher. ### `fan_in` / `fan_out` — graph coupling @@ -366,6 +380,16 @@ node with no internal coupling on one side (`fan_in` or `fan_out` = 0) gets `hk = 0`, which is dropped. See [HK.md](HK.md) for the full rationale. +### `cycle` — dependency-cycle tag + +Not a number but a **categorical** node attribute: set on every module that +participates in a dependency cycle, naming the cycle's kind — `mutual` (two units +import each other, A ↔ B) or `chain` (a strongly-connected component of three or +more, A → B → C → A). It comes from the graph's cycle pass (over the same flow +edges as `fan_in` / `fan_out`), is left absent on acyclic modules, and is what +the Acyclic Dependencies Principle (ADP) gate and scorecard key on. See +[../../docs/cycles.md](../../docs/cycles.md) for how the components are detected. + ### Project averages (the `stats` block) Finally, the pipeline emits a per-project **mean** of each tracked metric From c2e05191b918db395fefbb3e421218abc266f6eb Mon Sep 17 00:00:00 2001 From: Roman Fedorov <Roman.Fedorov@constructor.tech> Date: Thu, 25 Jun 2026 21:34:08 +0300 Subject: [PATCH 38/40] feat(cli): replace `ai` with `docs <subject>`; drop `report --doc` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold the offline doc surface into one analysis-free command: code-ranker docs <subject> — ai (the playbook), metrics/principles (indexes), a metric category (loc, …), a metric key (sloc, hk), or a principle id (SRP, incl. project [principles.<ID>]); a bare/unknown subject prints a 2-level catalog. Specs are built from config + plugin with no source walk and no [input]. Subject matching is separator/case-insensitive (fan_in = Fan-in = 'FAN in'). - Remove the `ai` subcommand (now `docs ai`) and the `report --doc <ID>` flag (now `docs <ID>`, which also appends a metric's prose doc). - templates.rs gains a spec-based resolve_doc_from_specs + normalize_id + metric_doc_stem (key->corpus-doc by normalized stem, replacing the parse of a doc id out of remediation prose). merge_project_principles moves to config so pipeline and docs share it without a docs->pipeline hub edge. - Drop boilerplate metric remediation from builtin.toml; check's fix line is auto-derived as 'code-ranker docs <key>' (custom [rules.checks]/[metrics] fixes kept). Fixes the broken docs Fan-out pointer. - The closing 'check OK' timing line is limited to check/report; docs is quiet. Docs synced (CLI/PRD/DESIGN/ERRORS/ai-skill/templates/README/AI.md/contrib). No format-version bump: removing --doc rides this branch's existing major CONFIG_VERSION move (4.0); --doc never shipped in a merged release. make all green (coverage 96.15%). --- README.md | 8 +- contrib/prompt-eval-metrics.py | 6 +- contrib/prompting-self-improve.md | 22 +- crates/code-ranker-cli/src/ai.rs | 47 -- crates/code-ranker-cli/src/ai_test.rs | 41 -- crates/code-ranker-cli/src/analyze.rs | 1 - crates/code-ranker-cli/src/cli.rs | 36 +- crates/code-ranker-cli/src/config/mod.rs | 1 + crates/code-ranker-cli/src/config/model.rs | 20 + crates/code-ranker-cli/src/config/rules.rs | 11 +- crates/code-ranker-cli/src/docs.rs | 437 ++++++++++++++++++ crates/code-ranker-cli/src/docs_test.rs | 147 ++++++ crates/code-ranker-cli/src/main.rs | 32 +- crates/code-ranker-cli/src/pipeline.rs | 22 +- crates/code-ranker-cli/src/pipeline_test.rs | 2 +- crates/code-ranker-cli/src/recommend.rs | 23 +- crates/code-ranker-cli/src/recommend_test.rs | 15 +- crates/code-ranker-cli/src/report.rs | 25 +- crates/code-ranker-cli/src/templates.rs | 134 ++++-- crates/code-ranker-cli/src/templates_test.rs | 81 ++-- crates/code-ranker-cli/tests/e2e.rs | 90 ++-- crates/code-ranker-graph/metrics/builtin.toml | 9 +- crates/code-ranker-graph/metrics/prompt.md | 2 +- crates/code-ranker-graph/src/builtin_test.rs | 2 +- .../c/tests/sample/code-ranker-report.json | 7 +- .../cpp/tests/sample/code-ranker-report.json | 7 +- .../tests/sample/code-ranker-report.json | 7 +- .../go/tests/sample/code-ranker-report.json | 7 +- .../tests/sample/code-ranker-report.json | 11 +- .../tests/sample/code-ranker-report.json | 6 +- .../tests/sample/code-ranker-report.json | 11 +- .../rust/tests/sample/code-ranker-report.json | 11 +- .../tests/sample/code-ranker-report.json | 11 +- docs/PRD.md | 3 +- docs/ai-skill.md | 12 +- docs/code-ranker-cli/CLI.md | 84 ++-- docs/code-ranker-cli/DESIGN.md | 17 +- docs/code-ranker-cli/ERRORS.md | 12 +- docs/code-ranker-cli/PRD.md | 28 +- docs/templates.md | 15 +- languages/base/AI.md | 25 +- 41 files changed, 1019 insertions(+), 469 deletions(-) delete mode 100644 crates/code-ranker-cli/src/ai.rs delete mode 100644 crates/code-ranker-cli/src/ai_test.rs create mode 100644 crates/code-ranker-cli/src/docs.rs create mode 100644 crates/code-ranker-cli/src/docs_test.rs diff --git a/README.md b/README.md index aa6f20d1..88c292cb 100644 --- a/README.md +++ b/README.md @@ -35,14 +35,14 @@ code-ranker always runs **entirely on your machine**. It makes **no network call ## AI agents friendly -**Hand your codebase to an AI agent and let it fix the worst spot.** code-ranker is built to feed work straight to an AI coding agent (Claude Code, Cursor, …). Attach the short playbook [docs/ai-skill.md](docs/ai-skill.md) to your agent's context — it teaches the agent which two metrics matter (dependency cycles `ADP`, coupling `HK`) and the exact fix loop (scorecard → snapshot → fix → re-check → before/after report). +**Hand your codebase to an AI agent and let it fix the worst spot.** code-ranker is built to feed work straight to an AI coding agent (Claude Code, Cursor, …). Run **`code-ranker docs ai`** in your repo — it prints a short, offline playbook (no network) that teaches the agent which two metrics matter (dependency cycles `ADP`, coupling `HK`) and the exact fix loop (scorecard → snapshot → fix → re-check → before/after report), tailored to your project's language. Then just ask, e.g.: -- *"Read `https://raw.githubusercontent.com/ffedoroff/code-ranker/main/docs/ai-skill.md`. Find the worst dependency cycle in this project and propose a refactor that breaks it — show me the plan before changing code."* -- *"Read `https://raw.githubusercontent.com/ffedoroff/code-ranker/main/docs/ai-skill.md`. Find the most complex / highest-HK file and analyze how to split it; explain what the split buys for me (lower coupling, smaller blast radius). Take a **before report**, apply the split, take an **after report**, and show me the **HTML diff**."* +- *"Run `code-ranker docs ai` and follow it: find the worst dependency cycle in this project and propose a refactor that breaks it — show me the plan before changing code."* +- *"Run `code-ranker docs ai` for the playbook, then find the most complex / highest-HK file and analyze how to split it; explain what the split buys for me (lower coupling, smaller blast radius). Take a **before report**, apply the split, take an **after report**, and show me the **HTML diff**."* -The agent drives the CLI itself — `ai-skill.md` already spells out the commands and the loop, so no glue is needed. +The agent drives the CLI itself — `code-ranker docs ai` spells out the commands and the loop, so no glue is needed. (Prefer a file in context? The same playbook lives at [docs/ai-skill.md](docs/ai-skill.md).) ## What it finds diff --git a/contrib/prompt-eval-metrics.py b/contrib/prompt-eval-metrics.py index a4643765..a5185add 100755 --- a/contrib/prompt-eval-metrics.py +++ b/contrib/prompt-eval-metrics.py @@ -147,11 +147,11 @@ def parse(x): # doc reads / rereads docs = [] for _, cmd in cmds: - if "--doc " in cmd: - tail = cmd.split("--doc ", 1)[1].split() + if "docs " in cmd: + tail = cmd.split("docs ", 1)[1].split() if tail: docs.append(tail[0]) - m["read_doc_ai"] = 1 if any(d == "AI" for d in docs) else 0 + m["read_doc_ai"] = 1 if any(d.lower() == "ai" for d in docs) else 0 fl = (focus or "").lower() aliases = {"adp", "cycle", "cycles"} if fl in CYCLE_FOCI else {fl} m["read_doc_focus"] = 1 if any(d.lower() in aliases for d in docs) else 0 diff --git a/contrib/prompting-self-improve.md b/contrib/prompting-self-improve.md index c536d58a..6c6eb364 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/<lang>/config.toml`). - **scaffolding** (intro / doc-note / task / focus prose) — `crates/code-ranker-graph/metrics/prompt.md`. -- **the full reference doc** the agent reads via `--doc <FOCUS>` — +- **the full reference doc** the agent reads via `docs <FOCUS>` — `languages/<lang>/<FOCUS>.md` (e.g. `ADP.md`), and the offline entry point - `languages/base/AI.md` (`--doc AI`). + `languages/base/AI.md` (`docs ai`). Change the **smallest** lever that fixes the observed failure. @@ -77,7 +77,7 @@ Change the **smallest** lever that fixes the observed failure. `<FOCUS>.md` doc) or the per-language `config.toml` prompt override — **never** in the language-neutral `languages/base/AI.md` or the neutral `defaults.toml` prompt. When a cheaper tier fails for want of a language-specific remedy, the base lever stays generic -("read `--doc <principle>` — it has the cause and smallest fix for *your* language") and +("read `docs <principle>` — it has the cause and smallest fix for *your* language") and the specifics live in the per-language doc it points at. Putting a Rust example in `base/` leaks into every other language's output. @@ -151,7 +151,7 @@ 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 report --doc AI` (overview + catalog) and `--doc <FOCUS>` (the deep + `code-ranker docs ai` (overview + catalog) and `docs <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`. @@ -273,7 +273,7 @@ Layout (one build → one `<timestamp>_<CR_SHA>` 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 `--doc 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 report --doc AI`, then fix the worst `<FOCUS>` in this + > Read `code-ranker docs ai`, then fix the worst `<FOCUS>` 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 report --doc AI\`, then fix the worst <FOCUS>…" --model haiku + cd PROJECT && claude -p "Read \`code-ranker docs ai\`, then fix the worst <FOCUS>…" --model haiku ``` - **Other agents** (Cursor, …): open a **New Chat** (not a continued thread), select @@ -312,9 +312,9 @@ and note in `metrics.csv` which basis the run used. ### Saving the chat The transcript is the **primary tuning data** — it shows *where* a cheaper model -diverged (skipped `--doc`, picked the wrong cycle, hacked the metric). Save it raw, +diverged (skipped `docs`, picked the wrong cycle, hacked the metric). Save it raw, **verbatim, no summary**, into `$RUN/chat.*`. It must include the bootstrap -(`--doc AI` / `--doc <FOCUS>` reads), the task, and **every** assistant turn — its +(`docs ai` / `docs <FOCUS>` 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 `--doc AI` / `--doc <FOCUS>` | +| `read_doc_ai` / `read_doc_focus` | clarity | transcript | 1/0 — read `docs ai` / `docs <FOCUS>` | | `doc_reread` | clarity | transcript | ↓ times a doc was read more than once (a re-read signals the prompt/doc wasn't clear the first time) | | `planned_before_edit` | clarity | transcript | 1/0 — proposed a plan before editing | | `used_generated_prompt` | adherence | transcript | 1/0 — actually fetched the tool's fix-prompt (`--output.prompt` / `--prompt`) vs improvising | @@ -513,7 +513,7 @@ Track one row per run so the sweep is auditable: | date | cr version+commit | PROJECT | FOCUS | MODEL | iter | branch | verdict (Δ) | tests | quality 1–5 | tokens | time (s) | notes / failure class | |------|-------------------|---------|-------|-------|------|--------|-------------|-------|-------------|--------|----------|----------------------| | … | 4.0.0 @abc123 | … | cycle | opus | 1 | opus-cycle-1 | improved (−2 cycles) | pass | 5 | 49.7k | 196 | reference | -| … | 4.0.0 @abc123 | … | cycle | sonnet | 1 | sonnet-cycle-1 | neutral (0) | pass | 2 | 88k | 310 | skipped `--doc`, hacked one edge | +| … | 4.0.0 @abc123 | … | cycle | sonnet | 1 | sonnet-cycle-1 | neutral (0) | pass | 2 | 88k | 310 | skipped `docs`, hacked one edge | `tokens` and `time (s)` are the cost axis at a glance (full breakdown — `tool_calls`, `commands`, `input_tokens`, `output_tokens`, `wall_s` — lives in diff --git a/crates/code-ranker-cli/src/ai.rs b/crates/code-ranker-cli/src/ai.rs deleted file mode 100644 index 363f1214..00000000 --- a/crates/code-ranker-cli/src/ai.rs +++ /dev/null @@ -1,47 +0,0 @@ -//! The `ai` subcommand: print the offline AI-agent playbook to stdout. -//! -//! Unlike `check` / `report` it never analyzes — it only resolves which language -//! plugin applies (explicit `--plugin` > config `plugin` > auto-detect from the -//! `[input]` directory's markers) to choose the output: -//! - **resolved** → the full embedded `base/AI.md` playbook + principle/metric -//! catalog (the agent can analyze, so no plugin-setup noise); -//! - **unresolved** (no marker, or ambiguous markers) → a brief product intro plus -//! how to select a plugin, with the catalog withheld until a language is chosen. - -use anyhow::Result; -use code_ranker_graph::version::CONFIG_VERSION; -use std::path::Path; - -use crate::{config, plugin, templates}; - -pub(crate) fn run(input: &Path, plugin_arg: Option<&str>, config_entries: &[String]) -> Result<()> { - // `ai` is a doc command: a missing or broken config must not fail it. Read the - // config best-effort, only for its `plugin` key. - let cfg_plugin = config::load(input, config_entries, &[], &[], &[]) - .ok() - .and_then(|loaded| loaded.config.plugin); - - let md = match plugin::resolve_plugin(plugin_arg, cfg_plugin.as_deref(), input) { - Ok(_) => templates::ai_doc()?, - Err(reason) => fill_select(&templates::ai_doc_intro()?, &reason.to_string()), - }; - - print!("{}", templates::with_trailing_newline(md)); - Ok(()) -} - -/// Fill the *Select a language* template authored in `base/AI.md` (returned in the -/// intro by [`templates::ai_doc_intro`]) with the live values: the resolver -/// diagnostic (`reason` — no marker / ambiguous markers), the built-in plugin names, -/// and the config-schema version. The prose lives in the doc; only the values are -/// injected here. -fn fill_select(intro: &str, reason: &str) -> String { - intro - .replace("{reason}", reason) - .replace("{plugins}", &plugin::names()) - .replace("{config_version}", CONFIG_VERSION) -} - -#[cfg(test)] -#[path = "ai_test.rs"] -mod tests; diff --git a/crates/code-ranker-cli/src/ai_test.rs b/crates/code-ranker-cli/src/ai_test.rs deleted file mode 100644 index f22625bd..00000000 --- a/crates/code-ranker-cli/src/ai_test.rs +++ /dev/null @@ -1,41 +0,0 @@ -use super::*; - -#[test] -fn fill_select_injects_live_values_into_the_doc_template() { - let reason = "ambiguous project in .: markers for multiple plugins found (rust, markdown) — pass --plugin to choose"; - let md = fill_select(&templates::ai_doc_intro().unwrap(), reason); - - // Intro + command list (the prose authored in base/AI.md) is kept… - assert!( - md.contains("code-ranker — AI agent skill"), - "intro head present" - ); - assert!( - md.contains("## Commands") && md.contains("**`help`**") && md.contains("**`report"), - "command list present" - ); - // …and the Select-a-language template is filled with live values. - assert!(md.contains("## Select a language"), "setup section present"); - assert!( - md.contains(reason), - "{{reason}} replaced with the diagnostic" - ); - assert!( - md.contains(&plugin::names()), - "{{plugins}} replaced with the registry names" - ); - assert!( - md.contains(&format!("version = \"{CONFIG_VERSION}\"")), - "{{config_version}} replaced with the live CONFIG_VERSION" - ); - - // No placeholder leaks… - for ph in ["{reason}", "{plugins}", "{config_version}"] { - assert!(!md.contains(ph), "placeholder {ph} fully substituted"); - } - // …and the catalog is withheld until a language is chosen. - assert!( - !md.contains("## Principles & metrics") && !md.contains("### ADP"), - "catalog omitted: {md}" - ); -} diff --git a/crates/code-ranker-cli/src/analyze.rs b/crates/code-ranker-cli/src/analyze.rs index b7c27fe4..5938899c 100644 --- a/crates/code-ranker-cli/src/analyze.rs +++ b/crates/code-ranker-cli/src/analyze.rs @@ -74,7 +74,6 @@ fn analyze_from_snapshot( cycles: cfg.rules.cycles, rules: cfg.rules, output: cfg.output, - templates: cfg.templates, }) } diff --git a/crates/code-ranker-cli/src/cli.rs b/crates/code-ranker-cli/src/cli.rs index 73ac4f92..82757dfb 100644 --- a/crates/code-ranker-cli/src/cli.rs +++ b/crates/code-ranker-cli/src/cli.rs @@ -275,33 +275,27 @@ pub(crate) enum Command { /// to shape the ranked module list. #[arg(long = "prompt", value_name = "PRINCIPLE | METRIC")] prompt_id: Option<String>, - - /// Print the principle/metric doc Markdown for one id to stdout and exit - /// (e.g. `--doc HK`) — the resolved `languages/<lang>/<ID>.md`, with any - /// `[templates.languages.…]` override applied. No artifacts are written. - #[arg(long = "doc", value_name = "PRINCIPLE | METRIC")] - doc_id: Option<String>, }, - /// Print the offline AI-agent playbook to stdout — no analysis, always exits 0. - /// Output adapts to whether a language plugin can be resolved (explicit - /// `--plugin` > config `plugin` > auto-detect from `[input]` markers): when one - /// is resolved, it prints the full playbook + principle/metric catalog; when - /// none can be resolved (no marker, or ambiguous markers), it prints a brief - /// product intro plus how to select a plugin, and omits the catalog. - Ai { - /// Directory whose markers decide the output mode (default: current dir). - /// No analysis is run — only plugin resolution. - #[arg(default_value = ".")] - input: PathBuf, - - /// Plugin: rust | python | javascript | auto. Resolves the mode explicitly - /// (skips auto-detection). + /// Print a reference doc to stdout — no analysis, no `[input]`. The `<subject>` + /// is `ai` (the offline AI-agent playbook), `metrics` or `principles` (an index + /// of each), a metric category (`loc`, `complexity`, …), a metric key (`sloc`, + /// `hk`, …), or a principle id (`SRP`, `ADP`, … — including project-defined + /// `[principles.<ID>]` and `[metrics.<key>]`). With no subject it prints a + /// catalog of every option. Config is auto-discovered from the current directory + /// (override with `--config`); `--plugin` resolves the language explicitly. + Docs { + /// What to print: `ai` | `metrics` | `principles` | a category | a metric + /// | a principle id. Omit to list every available subject. + subject: Option<String>, + + /// Plugin: rust | python | javascript | auto. Resolves the language whose + /// principles / metric refinements drive the docs (skips auto-detection). #[arg(long)] plugin: Option<String>, /// Config file path, or inline `KEY=VALUE` override (repeatable) — consulted - /// only for its `plugin` key when resolving the mode. + /// for the `plugin` key and any project `[principles]` / `[metrics]`. #[arg(long, value_name = "PATH | KEY=VALUE")] config: Vec<String>, }, diff --git a/crates/code-ranker-cli/src/config/mod.rs b/crates/code-ranker-cli/src/config/mod.rs index d4ecee25..1487d0c2 100644 --- a/crates/code-ranker-cli/src/config/mod.rs +++ b/crates/code-ranker-cli/src/config/mod.rs @@ -13,6 +13,7 @@ pub mod violations; pub use ignore::apply_ignore; pub use load::load; +pub(crate) use model::merge_project_principles; pub use model::{CycleRules, OutputArtifact, OutputConfig, RulesConfig, TemplatesConfig}; pub use rules::{apply_cycle_rules, rule_doc, rule_tuning}; pub use violations::{Violation, check_violations}; diff --git a/crates/code-ranker-cli/src/config/model.rs b/crates/code-ranker-cli/src/config/model.rs index 1dc7bc9d..72fe47d1 100644 --- a/crates/code-ranker-cli/src/config/model.rs +++ b/crates/code-ranker-cli/src/config/model.rs @@ -146,6 +146,26 @@ impl PrincipleDef { } } +/// Merge the project's `[principles.<ID>]` over a plugin's principle catalog: a +/// same-id project principle replaces the plugin's (in place, keeping catalog +/// order), a new id is appended. So a project can recommend / scorecard / document +/// its own custom metric. Lives here (next to [`PrincipleDef`]) so both the +/// analysis pipeline and the analysis-free `docs` command share one merge without +/// either depending on the other. +pub(crate) fn merge_project_principles( + mut catalog: Vec<code_ranker_plugin_api::Principle>, + project: &std::collections::BTreeMap<String, PrincipleDef>, +) -> Vec<code_ranker_plugin_api::Principle> { + for (id, def) in project { + let p = def.to_principle(id); + match catalog.iter_mut().find(|e| e.id == p.id) { + Some(existing) => *existing = p, + None => catalog.push(p), + } + } + catalog +} + /// `[levels]` — opt-in extra graph levels beyond `files`. #[derive(Debug, Clone, Copy, Deserialize, Default)] #[serde(default, deny_unknown_fields)] diff --git a/crates/code-ranker-cli/src/config/rules.rs b/crates/code-ranker-cli/src/config/rules.rs index cc857e39..435f87de 100644 --- a/crates/code-ranker-cli/src/config/rules.rs +++ b/crates/code-ranker-cli/src/config/rules.rs @@ -60,10 +60,19 @@ pub fn rule_doc( } let metric = id.rsplit('.').next().unwrap_or(id); let s = node_attributes.get(metric)?; + // A metric's `fix` is its own `remediation` when one is authored (a project + // `[metrics.<key>]` may set a custom fix); otherwise auto-derive the pointer to + // its doc from the key, so the built-in catalog carries no duplicated boilerplate + // and the command always names the correct subject (`docs <key>`). + let fix = s.remediation.clone().or_else(|| { + Some(format!( + "Run `code-ranker docs {metric}` and follow its instructions." + )) + }); Some(RuleDoc { title: s.name.clone().or_else(|| s.label.clone()), why: s.description.clone(), - fix: s.remediation.clone(), + fix, }) } diff --git a/crates/code-ranker-cli/src/docs.rs b/crates/code-ranker-cli/src/docs.rs new file mode 100644 index 00000000..159ed404 --- /dev/null +++ b/crates/code-ranker-cli/src/docs.rs @@ -0,0 +1,437 @@ +//! The `docs <subject>` command: print a reference doc to stdout. No analysis — +//! it resolves the merged config (auto-discovered from the current directory) and +//! the language plugin best-effort, then builds the principle + metric + category +//! specs from config + plugin (the same specs an analyzed snapshot carries, minus +//! the graph). Subjects: +//! - `ai` → the offline AI-agent playbook (resolved plugin → full +//! playbook + catalog; none → a brief intro + plugin-setup guidance); +//! - `metrics` → an index of every metric, grouped by category; +//! - `principles` → an index of every design principle; +//! - `<category>` → that category (`loc`, `complexity`, …) + its member metrics; +//! - `<metric>` → that metric's spec card, plus its full doc when one exists; +//! - `<principle>` → its full doc (or a synthetic card for a doc-less custom one); +//! - anything else (or no subject) → a friendly catalog of every subject. +//! +//! Categories and metrics are read from the spec dictionaries; principle ids and +//! custom metrics declared in the project config (`[principles.<ID>]` / +//! `[metrics.<key>]`) are first-class subjects too. + +use anyhow::{Result, bail}; +use code_ranker_graph::version::CONFIG_VERSION; +use code_ranker_plugin_api::Principle; +use code_ranker_plugin_api::level::{AttributeGroup, AttributeSpec}; +use code_ranker_plugin_api::plugin::PluginInput; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use crate::config::{self, TemplatesConfig}; +use crate::{plugin, templates}; + +/// Everything the `docs` subjects render, built from config + plugin with no graph. +struct DocSpecs { + principles: Vec<Principle>, + /// Metric/coupling specs by key (central catalog ⊕ plugin refinement ⊕ project + /// `[metrics.<key>]`). + node_attributes: BTreeMap<String, AttributeSpec>, + /// Category (group) label/description by key. + groups: BTreeMap<String, AttributeGroup>, + templates: TemplatesConfig, +} + +/// Print the doc for `subject` (or the catalog when it is absent / unknown). +pub(crate) fn run( + subject: Option<&str>, + plugin_arg: Option<&str>, + config_entries: &[String], +) -> Result<()> { + // `ai` is special: it needs no specs and has its own resolved/unresolved modes. + if subject.is_some_and(|s| templates::normalize_id(s) == "ai") { + return run_ai(plugin_arg, config_entries); + } + + let specs = build_specs(plugin_arg, config_entries); + + let Some(subject) = subject else { + // Bare `docs`: the catalog is the help, so exit 0. + print!( + "{}", + templates::with_trailing_newline(render_catalog(&specs, None)) + ); + return Ok(()); + }; + + // Every subject is matched on its normalized form (case/separator-insensitive), + // 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)); + } else if want == "principles" { + emit(render_principles_index(&specs)); + } else if let Some(cat) = category_key(&specs, subject) { + emit(render_category(&specs, &cat)); + } else if let Some(p) = specs + .principles + .iter() + .find(|p| templates::normalize_id(&p.id) == want) + { + emit(render_principle(&specs, &p.id)?); + } else if let Some(key) = specs + .node_attributes + .keys() + .find(|k| templates::normalize_id(k) == want) + { + emit(render_metric(&specs, key)); + } else { + // Unknown subject: print the catalog so the caller sees every option, then + // fail (non-zero) — it was a real lookup miss, not a help request. + emit(render_catalog(&specs, Some(subject))); + bail!("unknown docs subject {subject:?} — see the list above"); + } + Ok(()) +} + +fn emit(md: String) { + print!("{}", templates::with_trailing_newline(md)); +} + +/// The `docs ai` playbook: resolve the plugin best-effort (like the rest of `docs`, +/// from `.`), then serve the full playbook or, when none resolves, the intro + a +/// filled-in *Select a language* template. +fn run_ai(plugin_arg: Option<&str>, config_entries: &[String]) -> Result<()> { + let input = Path::new("."); + let cfg_plugin = config::load(input, config_entries, &[], &[], &[]) + .ok() + .and_then(|loaded| loaded.config.plugin); + let md = match plugin::resolve_plugin(plugin_arg, cfg_plugin.as_deref(), input) { + Ok(_) => templates::ai_doc()?, + Err(reason) => fill_select(&templates::ai_doc_intro()?, &reason.to_string()), + }; + emit(md); + Ok(()) +} + +/// Fill the *Select a language* template (authored in `base/AI.md`) with the live +/// values: the resolver diagnostic, the built-in plugin names, the config version. +fn fill_select(intro: &str, reason: &str) -> String { + intro + .replace("{reason}", reason) + .replace("{plugins}", &plugin::names()) + .replace("{config_version}", CONFIG_VERSION) +} + +/// Build the doc specs from config + plugin, no analysis. Best-effort throughout: +/// a missing config yields the built-in defaults, a *broken* one is ignored (the +/// central catalog + plugin still answer most subjects), and an unresolved plugin +/// just drops the language-specific principles/refinements. +fn build_specs(plugin_arg: Option<&str>, config_entries: &[String]) -> DocSpecs { + let input = Path::new("."); + + // Central, language-neutral metric specs + their category groups. + let (default_metric_specs, metric_groups) = code_ranker_graph::metric_specs(); + let (coupling_specs, coupling_groups) = code_ranker_graph::coupling_specs(); + let mut groups = BTreeMap::new(); + groups.extend(metric_groups); + groups.extend(coupling_groups); + + let cfg = config::load(input, config_entries, &[], &[], &[]) + .ok() + .map(|loaded| loaded.config); + + let cfg_plugin = cfg.as_ref().and_then(|c| c.plugin.clone()); + let plugin_name = plugin::resolve_plugin(plugin_arg, cfg_plugin.as_deref(), input).ok(); + + let pinput = cfg + .as_ref() + .map_or_else(default_plugin_input, |c| PluginInput { + ignore: c.ignore.paths.clone(), + ignore_tests: c.ignore.tests, + gitignore: c.ignore.gitignore, + ignore_files: c.ignore.ignore_files, + hidden: c.ignore.hidden, + }); + + // Metrics: central catalog refined by the active plugin, plus coupling and the + // project's node-scope declarative metrics (built-ins win a key collision). + let metric_specs = match &plugin_name { + Some(n) => plugin::metric_specs(n, default_metric_specs), + None => default_metric_specs, + }; + let mut node_attributes: BTreeMap<String, AttributeSpec> = BTreeMap::new(); + node_attributes.extend(metric_specs); + node_attributes.extend(coupling_specs); + if let Some(c) = &cfg { + for (k, d) in &c.metrics { + if d.scope == code_ranker_graph::Scope::Node { + node_attributes + .entry(k.clone()) + .or_insert_with(|| d.to_attribute_spec()); + } + } + } + + // Principles: plugin catalog overlaid with the project's `[principles.<ID>]`. + let catalog = match &plugin_name { + Some(n) => plugin::principles(n, &pinput), + None => Vec::new(), + }; + let principles = match &cfg { + Some(c) => config::merge_project_principles(catalog, &c.principles), + None => catalog, + }; + + let templates = cfg.map(|c| c.templates).unwrap_or_default(); + + DocSpecs { + principles, + node_attributes, + groups, + templates, + } +} + +/// A neutral `PluginInput` for the no-config fallback (a broken config file). The +/// principle/metric-spec hooks barely read these, so the defaults only affect the +/// rare degraded path. +fn default_plugin_input() -> PluginInput { + PluginInput { + ignore: Vec::new(), + ignore_tests: true, + gitignore: true, + ignore_files: true, + hidden: false, + } +} + +// ── subject resolution helpers ──────────────────────────────────────────────── + +/// Every category key: the defined groups plus any `group` a metric spec references +/// (a metric may name a category that ships no `[categories.<key>]` label). +fn category_keys(specs: &DocSpecs) -> BTreeSet<String> { + let mut keys: BTreeSet<String> = specs.groups.keys().cloned().collect(); + for spec in specs.node_attributes.values() { + if let Some(g) = &spec.group { + keys.insert(g.clone()); + } + } + keys +} + +/// The canonical category key matching `subject` (separator/case-insensitive), if any. +fn category_key(specs: &DocSpecs, subject: &str) -> Option<String> { + let want = templates::normalize_id(subject); + category_keys(specs) + .into_iter() + .find(|k| templates::normalize_id(k) == want) +} + +/// The metrics in one category, by key (sorted — `BTreeMap` order). +fn metrics_in_category<'a>(specs: &'a DocSpecs, key: &str) -> Vec<(&'a String, &'a AttributeSpec)> { + specs + .node_attributes + .iter() + .filter(|(_, s)| s.group.as_deref() == Some(key)) + .collect() +} + +// ── rendering ───────────────────────────────────────────────────────────────── + +/// A metric's display name: `name` › `label` › the key itself. +fn metric_name<'a>(spec: &'a AttributeSpec, key: &'a str) -> &'a str { + spec.name + .as_deref() + .or(spec.label.as_deref()) + .unwrap_or(key) +} + +/// The first line of a (possibly multi-line, `<br>`-encoded) description. +fn one_line(desc: &str) -> &str { + desc.split("<br>").next().unwrap_or(desc).trim() +} + +/// A category's label (› its key) and optional description. +fn category_label(specs: &DocSpecs, key: &str) -> String { + specs + .groups + .get(key) + .and_then(|g| g.label.clone()) + .unwrap_or_else(|| key.to_string()) +} + +/// Strip a leading `ID — ` from a principle title so the listing column is tight. +fn principle_title(p: &Principle) -> &str { + p.title + .split_once(" — ") + .map(|(_, rest)| rest) + .unwrap_or(&p.title) +} + +/// The categories section shared by `docs metrics` and the catalog: each category +/// header (`key: Label — description`) followed by its member metrics. +fn categories_block(specs: &DocSpecs) -> String { + let mut out = String::new(); + let cats = category_keys(specs); + for key in &cats { + out.push_str(&format!("\n {key}: {}", category_label(specs, key))); + if let Some(d) = specs.groups.get(key).and_then(|g| g.description.as_deref()) { + out.push_str(&format!(" — {d}")); + } + out.push('\n'); + for (k, spec) in metrics_in_category(specs, key) { + out.push_str(&format!(" - {k}: {}\n", metric_name(spec, k))); + } + } + // Metrics with no category (e.g. the categorical `cycle`): list them too. + let uncategorized: Vec<_> = specs + .node_attributes + .iter() + .filter(|(_, s)| s.group.is_none()) + .collect(); + if !uncategorized.is_empty() { + out.push_str("\n (uncategorized)\n"); + for (k, spec) in uncategorized { + out.push_str(&format!(" - {k}: {}\n", metric_name(spec, k))); + } + } + out +} + +/// The principles section shared by `docs principles` and the catalog. +fn principles_block(specs: &DocSpecs) -> String { + if specs.principles.is_empty() { + return " (none — no language plugin resolved here)\n".to_string(); + } + specs + .principles + .iter() + .map(|p| format!(" - {}: {}\n", p.id, principle_title(p))) + .collect() +} + +/// `docs metrics`: every metric, grouped by category. +fn render_metrics_index(specs: &DocSpecs) -> String { + format!( + "Metrics — print one with `code-ranker docs <metric>`:\n{}", + categories_block(specs) + ) +} + +/// `docs principles`: every design principle. +fn render_principles_index(specs: &DocSpecs) -> String { + format!( + "Principles — print one with `code-ranker docs <ID>`:\n\n{}", + principles_block(specs) + ) +} + +/// `docs <category>`: the category label + description + its member metrics. +fn render_category(specs: &DocSpecs, key: &str) -> String { + let mut out = format!("{key}: {}", 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 <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() { + out.push_str(&format!(" — {}", one_line(d))); + } + out.push('\n'); + } + out +} + +/// `docs <metric>`: the spec card (label / name / category / description / formula), +/// then the full prose doc appended when one exists (e.g. `hk` → `HK.md`). +fn render_metric(specs: &DocSpecs, subject: &str) -> String { + let (key, spec) = specs + .node_attributes + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(subject)) + .expect("caller checked the key exists"); + let name = metric_name(spec, key); + let mut out = format!("# {key}: {name}"); + if let Some(short) = spec.short.as_deref().filter(|s| *s != name) { + out.push_str(&format!(" ({short})")); + } + out.push('\n'); + if let Some(g) = &spec.group { + out.push_str(&format!("\nCategory: {g} — {}\n", category_label(specs, g))); + } + if let Some(d) = spec.description.as_deref() { + out.push_str(&format!("\n{}\n", d.replace("<br>", "\n"))); + } + if let Some(f) = &spec.formula { + out.push_str(&format!("\nFormula: {f}\n")); + } + // A metric whose `remediation` points at a corpus doc (e.g. `hk` → `HK.md`) + // gets that full doc appended — so `docs hk` is the complete reference. + if let Ok(prose) = templates::resolve_doc_from_specs( + &specs.principles, + &specs.node_attributes, + &specs.templates, + key, + ) { + out.push_str(&format!("\n---\n\n{}\n", prose.trim_end())); + } + out +} + +/// `docs <principle>`: the full prose doc, or — for a project-defined principle with +/// no doc file — a synthetic card from its title / sort-metric / prompt. +fn render_principle(specs: &DocSpecs, subject: &str) -> Result<String> { + match templates::resolve_doc_from_specs( + &specs.principles, + &specs.node_attributes, + &specs.templates, + subject, + ) { + Ok(md) => Ok(md), + Err(_) => { + let p = specs + .principles + .iter() + .find(|p| p.id.eq_ignore_ascii_case(subject)) + .expect("caller checked the principle exists"); + let mut out = format!( + "# {}: {}\n\nSort metric: `{}`\n", + p.id, p.title, p.sort_metric + ); + if !p.prompt.is_empty() { + out.push_str(&format!("\n{}\n", p.prompt)); + } + Ok(out) + } + } +} + +/// The catalog of every subject — shown for a bare `docs` (help) and, with a lead +/// note, for an unknown subject. A uniform two-level tree: each group (a metric +/// category, then `principles`) on its own line, its members indented beneath. Every +/// name on every line — group or member — is itself a valid `docs <subject>`. +fn render_catalog(specs: &DocSpecs, unknown: Option<&str>) -> String { + let mut out = String::new(); + if let Some(s) = unknown { + out.push_str(&format!("Unknown docs subject `{s}`.\n\n")); + } + out.push_str("code-ranker docs <subject> — print a reference doc to stdout (no analysis).\n"); + out.push_str(&categories_block(specs)); + // Principles render as one more group, exactly like a metric category. + out.push_str("\n principles: Design principles\n"); + out.push_str( + &specs + .principles + .iter() + .map(|p| format!(" - {}: {}\n", p.id, principle_title(p))) + .collect::<String>(), + ); + out.push_str( + "\nCall `docs` with any name above — e.g. `docs principles`, `docs KISS`, \ + `docs cloc`, `docs complexity`. Also `docs ai` (the agent playbook) and \ + `docs metrics` (the full metric index).\n", + ); + out +} + +#[cfg(test)] +#[path = "docs_test.rs"] +mod tests; diff --git a/crates/code-ranker-cli/src/docs_test.rs b/crates/code-ranker-cli/src/docs_test.rs new file mode 100644 index 00000000..8d46019f --- /dev/null +++ b/crates/code-ranker-cli/src/docs_test.rs @@ -0,0 +1,147 @@ +use super::*; +use code_ranker_plugin_api::attrs::ValueType; +use code_ranker_plugin_api::level::{AttributeSpec, group}; + +/// A metric spec with the fields the cards read. +fn metric(label: &str, name: &str, desc: &str, category: &str) -> AttributeSpec { + let mut s = AttributeSpec::new(ValueType::Int, label); + s.name = Some(name.to_string()); + s.description = Some(desc.to_string()); + s.group = Some(category.to_string()); + s +} + +fn specs() -> DocSpecs { + let mut node_attributes = BTreeMap::new(); + node_attributes.insert( + "sloc".to_string(), + metric("Source", "Source lines", "Source lines of code.", "loc"), + ); + node_attributes.insert( + "blank".to_string(), + metric("Blank", "Blank lines", "Empty lines.", "loc"), + ); + let mut groups = BTreeMap::new(); + groups.insert( + "loc".to_string(), + group("Lines of Code", "Lines of code breakdown"), + ); + DocSpecs { + // A project-defined principle with no corpus doc and no `doc_url` — so it + // exercises the synthetic-card fallback (a real id like `SRP` would resolve + // to the embedded `base/SRP.md`). + principles: vec![Principle { + id: "TSR".into(), + label: "TSR".into(), + title: "TSR — Test Ratio".into(), + prompt: "Keep the test ratio healthy.".into(), + doc_url: None, + sort_metric: "hk".into(), + connections: vec![], + }], + node_attributes, + groups, + templates: TemplatesConfig::default(), + } +} + +#[test] +fn fill_select_injects_live_values_into_the_doc_template() { + let reason = "ambiguous project in .: markers for multiple plugins found (rust, markdown) — pass --plugin to choose"; + let md = fill_select(&templates::ai_doc_intro().unwrap(), reason); + + assert!( + md.contains("code-ranker — AI agent skill"), + "intro head present" + ); + assert!( + md.contains("## Commands") && md.contains("**`help`**") && md.contains("**`report"), + "command list present" + ); + assert!(md.contains("## Select a language"), "setup section present"); + assert!( + md.contains(reason), + "{{reason}} replaced with the diagnostic" + ); + assert!( + md.contains(&plugin::names()), + "{{plugins}} replaced with the registry names" + ); + assert!( + md.contains(&format!("version = \"{CONFIG_VERSION}\"")), + "{{config_version}} replaced with the live CONFIG_VERSION" + ); + for ph in ["{reason}", "{plugins}", "{config_version}"] { + assert!(!md.contains(ph), "placeholder {ph} fully substituted"); + } +} + +#[test] +fn category_subject_resolves_case_insensitively() { + let s = specs(); + assert_eq!(category_key(&s, "LOC").as_deref(), Some("loc")); + assert_eq!(category_key(&s, "nope"), None); +} + +#[test] +fn render_category_lists_label_description_and_members() { + let out = render_category(&specs(), "loc"); + assert!(out.contains("loc: Lines of Code"), "header: {out}"); + assert!( + out.contains("Lines of code breakdown"), + "description: {out}" + ); + // Member metrics, each with name + one-line description. + assert!( + out.contains("- sloc: Source lines — Source lines of code."), + "{out}" + ); + assert!(out.contains("- blank: Blank lines"), "{out}"); +} + +#[test] +fn render_metric_renders_the_spec_card() { + let out = render_metric(&specs(), "sloc"); + assert!(out.contains("# sloc: Source lines"), "title: {out}"); + assert!( + out.contains("Category: loc — Lines of Code"), + "category: {out}" + ); + assert!(out.contains("Source lines of code."), "description: {out}"); +} + +#[test] +fn render_principle_falls_back_to_a_synthetic_card_without_a_doc() { + // The custom `TSR` test principle has no `doc_url` and no corpus stem match, + // so resolution fails and the synthetic card is served. + let out = render_principle(&specs(), "tsr").unwrap(); + assert!(out.contains("# TSR: TSR — Test Ratio"), "{out}"); + assert!(out.contains("Sort metric: `hk`"), "{out}"); + assert!(out.contains("Keep the test ratio healthy."), "{out}"); +} + +#[test] +fn catalog_lists_every_subject_class() { + let out = render_catalog(&specs(), Some("zzz")); + assert!( + out.contains("Unknown docs subject `zzz`"), + "lead note: {out}" + ); + // Categories and their metrics (two-level). + assert!(out.contains("loc: Lines of Code"), "category group: {out}"); + assert!( + out.contains("- sloc: Source lines"), + "category member: {out}" + ); + // Principles render as one more group. + assert!( + out.contains("principles: Design principles"), + "principles group: {out}" + ); + assert!(out.contains("- TSR: Test Ratio"), "principle member: {out}"); + // Closing note points at ai / metrics and the call-anything hint. + assert!( + out.contains("Call `docs`") && out.contains("docs ai"), + "closing note: {out}" + ); +} diff --git a/crates/code-ranker-cli/src/main.rs b/crates/code-ranker-cli/src/main.rs index 289acc50..3fbc6e4c 100644 --- a/crates/code-ranker-cli/src/main.rs +++ b/crates/code-ranker-cli/src/main.rs @@ -7,12 +7,12 @@ // while binding no name, so nothing can accidentally reach into it. extern crate code_ranker_plugins as _; -mod ai; mod analyze; mod check; mod cli; mod compose; mod config; +mod docs; mod export; mod git; mod logger; @@ -42,11 +42,18 @@ fn main() -> Result<()> { "code-ranker {}", std::env::args().skip(1).collect::<Vec<_>>().join(" ") ); + // The run skeleton (`▶` startup + `✓ … — <time>` finish) is only meaningful for + // the analysis commands — `check` / `report` do real work worth timing. `docs` + // is a plain doc dump to stdout, so it stays quiet (no `▶` / `✓`); errors still + // surface on every command. + let timed = matches!(cli.command, Command::Check { .. } | Command::Report { .. }); // Startup line (verbose only): the exact command this run was invoked with. The // config it resolved is logged next, by `config::load`. The matching summary-tier // `✓ … — <time>` finish line is emitted by this timer. - logger::verbose(&format!("▶ {cmd}")); - let t = logger::Timer::start(&cmd); + let timer = timed.then(|| { + logger::verbose(&format!("▶ {cmd}")); + logger::Timer::start(&cmd) + }); let res = match cli.command { Command::Check { analyze, @@ -93,7 +100,6 @@ fn main() -> Result<()> { index, export_full_config, prompt_id, - doc_id, } => match export_full_config { // `--export-full-config PATH`: dump the effective config and exit; no analysis. Some(path) => export::export_full_config(&analyze, &path), @@ -121,22 +127,24 @@ fn main() -> Result<()> { top, index, prompt_id, - doc_id, }, ), }, - // `ai`: print the embedded AI playbook to stdout. No analysis — only plugin - // resolution, which picks the full playbook (resolved) vs. a brief intro + - // plugin-setup guidance (unresolved). See `ai.rs`. - Command::Ai { - input, + // `docs <subject>`: print a reference doc to stdout. No analysis — it builds + // the principle/metric/category specs from config + plugin and serves the + // playbook, an index, a category, a metric card, or a principle doc. See + // `docs.rs`. + Command::Docs { + subject, plugin, config, - } => ai::run(&input, plugin.as_deref(), &config), + } => docs::run(subject.as_deref(), plugin.as_deref(), &config), }; match &res { Ok(_) => { - t.finish(); + if let Some(t) = timer { + t.finish(); + } } Err(e) => logger::error(&format!("error: {e:#}")), } diff --git a/crates/code-ranker-cli/src/pipeline.rs b/crates/code-ranker-cli/src/pipeline.rs index 77519186..3ec0525f 100644 --- a/crates/code-ranker-cli/src/pipeline.rs +++ b/crates/code-ranker-cli/src/pipeline.rs @@ -27,8 +27,6 @@ pub(crate) struct Analyzed { /// `[output.<fmt>]` config: per-format `path` template and `enabled` flag /// (CLI flags still win — resolved in `run_report`). pub(crate) output: config::OutputConfig, - /// `[templates.languages.<lang>.<ID>]` doc-corpus overrides (for `--doc`). - pub(crate) templates: config::TemplatesConfig, } /// Directory input: load config, run the plugin, annotate the graphs, collect @@ -327,7 +325,7 @@ pub(crate) fn analyze_directory( // same-id project principle overrides the plugin's, a new id appends. So a // project can recommend / scorecard on its custom metric. let principles = - merge_project_principles(plugin::principles(&plugin_name, &input), &cfg.principles); + config::merge_project_principles(plugin::principles(&plugin_name, &input), &cfg.principles); // Prompt-Generator scaffolding: the built-in `metrics/prompt.md`, or a // `[templates] prompt = "<path>"` override read from disk (same `## <field>` @@ -361,27 +359,9 @@ pub(crate) fn analyze_directory( cycles: cfg.rules.cycles, rules: cfg.rules, output: cfg.output, - templates: cfg.templates, }) } -/// Merge the project's `[principles.<ID>]` over the plugin catalog: a same-id project -/// principle replaces the plugin's (in place, keeping catalog order), a new id is -/// appended. So a project can recommend / scorecard on its own custom metric. -fn merge_project_principles( - mut catalog: Vec<code_ranker_plugin_api::Principle>, - project: &BTreeMap<String, config::model::PrincipleDef>, -) -> Vec<code_ranker_plugin_api::Principle> { - for (id, def) in project { - let p = def.to_principle(id); - match catalog.iter_mut().find(|e| e.id == p.id) { - Some(existing) => *existing = p, - None => catalog.push(p), - } - } - catalog -} - /// The advisory `info`/`warning` tiers overlaid onto the metric specs (scorecard, /// viewer badges, prompt targeting), derived from the `check` gate so the report /// shows exactly what fails the gate. For each `[rules.thresholds.file]` limit the diff --git a/crates/code-ranker-cli/src/pipeline_test.rs b/crates/code-ranker-cli/src/pipeline_test.rs index 76db6e24..57d8d152 100644 --- a/crates/code-ranker-cli/src/pipeline_test.rs +++ b/crates/code-ranker-cli/src/pipeline_test.rs @@ -31,7 +31,7 @@ fn project_principles_override_then_append() { ..Default::default() }, ); - let merged = merge_project_principles(catalog, &project); + let merged = config::merge_project_principles(catalog, &project); assert_eq!(merged.len(), 2); let cpx = merged.iter().find(|p| p.id == "CPX").unwrap(); assert_eq!(cpx.sort_metric, "cyclomatic", "same id replaced in place"); diff --git a/crates/code-ranker-cli/src/recommend.rs b/crates/code-ranker-cli/src/recommend.rs index d9e4b61a..9593ff2f 100644 --- a/crates/code-ranker-cli/src/recommend.rs +++ b/crates/code-ranker-cli/src/recommend.rs @@ -75,8 +75,8 @@ pub fn resolve_focus(level: &LevelGraph, principles: &[Principle], name: &str) - /// Build a throwaway [`Principle`] that frames a **metric** as its own principle, so /// the metric-lens prompt reuses [`compose_prompt`] verbatim — the title is the -/// metric (`HK — Henry–Kafura`), the summary its `description`, the `doc_url` the -/// fix-prompt doc linked from its `remediation`, and the ranking axis the metric +/// metric (`HK — Henry–Kafura`), the summary its `description`, the `doc_url` its +/// base-corpus doc stem (resolved by key), and the ranking axis the metric /// itself. No SOLID principle is involved. Coupling metrics also pull the in/out /// connection lists (the HK fix workflow needs the crossroads); others omit them. pub fn synth_metric_principle( @@ -113,9 +113,9 @@ pub fn synth_metric_principle( } else { format!("{label} — {name}") }; - let doc_url = spec - .and_then(|s| s.remediation.as_deref()) - .and_then(doc_ref); + // The doc this metric's prompt points at — its base-corpus doc stem (`hk`→`HK`), + // or `None` for a metric that ships no prose doc. + let doc_url = crate::templates::metric_doc_stem(metric).map(str::to_string); let connections = if spec.and_then(|s| s.group.as_deref()) == Some("coupling") { vec!["in".to_string(), "out".to_string(), "common".to_string()] } else { @@ -132,19 +132,6 @@ pub fn synth_metric_principle( } } -/// The doc id a `remediation` string points at: the `<ID>` token after `--doc ` -/// in "Run `code-ranker report --doc <ID>` and follow its instructions." (the -/// `<ID>` is the canonical doc filename stem, e.g. `HK`, `Fan-in`, `ADP`). `None` -/// when the remediation names no doc. Shared with `templates::doc_rel_path`. -pub(crate) fn doc_ref(s: &str) -> Option<String> { - let after = s.split("--doc ").nth(1)?; - let id: String = after - .chars() - .take_while(|c| !c.is_whitespace() && *c != '`') - .collect(); - (!id.is_empty()).then_some(id) -} - /// Which threshold tier drives an output. `Auto` resolves to `Warning` when any /// module breaches it, else `Info` (the viewer's headline rule). #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/code-ranker-cli/src/recommend_test.rs b/crates/code-ranker-cli/src/recommend_test.rs index e0288a08..5050f616 100644 --- a/crates/code-ranker-cli/src/recommend_test.rs +++ b/crates/code-ranker-cli/src/recommend_test.rs @@ -189,7 +189,7 @@ fn compose_prompt_cycle_lists_modules_and_connections() { assert!(md.contains("# ADP — Acyclic"), "title heading: {md}"); assert!(md.contains("## Summary\n\nthe DAG rule"), "summary body"); assert!( - md.contains("`code-ranker report --doc ADP`"), + md.contains("`code-ranker docs ADP`"), "offline doc command (id substituted): {md}" ); assert!( @@ -728,8 +728,9 @@ fn parse_severity_rejects_garbage() { } /// `synth_metric_principle` frames a metric as its own "principle": title from -/// label+name, summary from description, `doc_url` extracted from the remediation -/// URL, and in/out/common connections for a coupling metric (none otherwise). +/// label+name, summary from description, `doc_url` resolved from the metric's +/// base-corpus doc (by key), and in/out/common connections for a coupling metric +/// (none otherwise). #[test] fn synth_metric_principle_frames_metric() { let mut hk = AttributeSpec::new(ValueType::Float, "HK"); @@ -737,7 +738,6 @@ fn synth_metric_principle_frames_metric() { hk.name = Some("Henry–Kafura".into()); hk.description = Some("coupling × size".into()); hk.group = Some("coupling".into()); - hk.remediation = Some("Run `code-ranker report --doc HK` and follow its instructions.".into()); let mut sloc = AttributeSpec::new(ValueType::Int, "SLOC"); sloc.description = Some("source lines".into()); let mut na: BTreeMap<String, AttributeSpec> = BTreeMap::new(); @@ -756,7 +756,7 @@ fn synth_metric_principle_frames_metric() { assert_eq!( p.doc_url.as_deref(), Some("HK"), - "doc id from the remediation's --doc reference" + "doc stem resolved from the metric key (hk → HK)" ); assert_eq!( p.connections, @@ -767,7 +767,10 @@ fn synth_metric_principle_frames_metric() { let q = synth_metric_principle(&level, &[], "sloc"); assert_eq!(q.title, "SLOC", "no `name` → title is the label"); assert!(q.connections.is_empty(), "non-coupling → no connections"); - assert!(q.doc_url.is_none(), "no remediation URL → no doc link"); + assert!( + q.doc_url.is_none(), + "metric ships no corpus doc → no doc link" + ); } /// `synth_metric_principle("cycle", …)` borrows the ADP principle (the one whose diff --git a/crates/code-ranker-cli/src/report.rs b/crates/code-ranker-cli/src/report.rs index 7f6145ac..8cece36b 100644 --- a/crates/code-ranker-cli/src/report.rs +++ b/crates/code-ranker-cli/src/report.rs @@ -37,8 +37,6 @@ pub(crate) struct ReportReco { pub(crate) index: Option<usize>, /// `--prompt <ID>`: print the named principle/metric prompt to stdout and exit. pub(crate) prompt_id: Option<String>, - /// `--doc <ID>`: print the named principle/metric doc Markdown to stdout and exit. - pub(crate) doc_id: Option<String>, } /// `report` — analyze (or read) the input and write artifacts. Which formats are @@ -50,9 +48,9 @@ pub(crate) fn run_report( out: ReportOutputs, reco: ReportReco, ) -> Result<()> { - // `--prompt <ID>` / `--doc <ID>`: analyze, print the one artifact to stdout, - // and exit — a direct dump that bypasses the file-artifact flags entirely. - if reco.prompt_id.is_some() || reco.doc_id.is_some() { + // `--prompt <ID>`: analyze, print the prompt to stdout, and exit — a direct + // dump that bypasses the file-artifact flags entirely. + if reco.prompt_id.is_some() { return run_direct(args, &reco); } @@ -225,23 +223,14 @@ pub(crate) fn run_report( Ok(()) } -/// `--prompt <ID>` / `--doc <ID>`: analyze the input and print one principle's AI -/// prompt or its doc Markdown to stdout, then exit. Standalone — no file artifacts -/// and none of the `--output.*` validation (so `--prompt HK --top 5` is fine). +/// `--prompt <ID>`: analyze the input and print one principle's AI fix-prompt to +/// stdout, then exit. Standalone — no file artifacts and none of the `--output.*` +/// validation (so `--prompt HK --top 5` is fine). Reference docs are a separate +/// concern, served analysis-free by the `docs` command. fn run_direct(args: &AnalyzeArgs, reco: &ReportReco) -> Result<()> { - if reco.prompt_id.is_some() && reco.doc_id.is_some() { - anyhow::bail!("--prompt and --doc are mutually exclusive"); - } let a = analyze_input(args, &[], &[])?; let snap = &a.snapshot; - // `--doc <ID>`: the resolved corpus Markdown (with any `[templates.…]` override). - if let Some(id) = &reco.doc_id { - let md = crate::templates::resolve_doc(snap, &a.templates, id)?; - print!("{}", crate::templates::with_trailing_newline(md)); - return Ok(()); - } - // `--prompt <ID>`: compose the named principle/metric prompt (same builder as // `--output.prompt`, but for the id you name, to stdout). let id = reco.prompt_id.as_deref().expect("prompt_id is set"); diff --git a/crates/code-ranker-cli/src/templates.rs b/crates/code-ranker-cli/src/templates.rs index 78f58af8..97ea9389 100644 --- a/crates/code-ranker-cli/src/templates.rs +++ b/crates/code-ranker-cli/src/templates.rs @@ -1,14 +1,15 @@ //! The embedded doc corpus and per-file overrides. //! //! The `languages/` principle/metric Markdown is compiled into the binary (see -//! `build.rs`), so the tool can serve a doc's text directly (`report --doc HK`) +//! `build.rs`), so the tool can serve a doc's text directly (`docs HK`) //! without a network fetch. A project may substitute any one fragment via //! `[templates.languages.<lang>.<ID>]` (read from disk instead of the embedded //! copy) — the doc analogue of overriding a config key. use crate::config::TemplatesConfig; use anyhow::{Context, Result}; -use code_ranker_graph::snapshot::Snapshot; +use code_ranker_plugin_api::{Principle, level::AttributeSpec}; +use std::collections::BTreeMap; // `CORPUS: &[(&str, &str)]` — `(<lang>/<ID>.md, contents)` — generated by build.rs. include!(concat!(env!("OUT_DIR"), "/corpus.rs")); @@ -18,56 +19,77 @@ fn corpus_doc(rel: &str) -> Option<&'static str> { CORPUS.iter().find(|(k, _)| *k == rel).map(|(_, v)| *v) } -/// The corpus path a doc id resolves to, as `<lang>/<ID>.md` — read from the -/// already-resolved `doc_url` of a matching principle (principle docs) or the -/// `remediation` URL of a matching metric (coupling/complexity docs). Both encode -/// the post-fallback corpus location (own `<lang>/` or the shared `base/`), so the -/// override key namespace lines up with where the doc actually lives. -fn doc_rel_path(snap: &Snapshot, id: &str) -> Option<String> { +/// Normalize an id / subject for matching: lowercase, keep only ASCII +/// alphanumerics — so `fan_in`, `Fan-in`, and `FAN in` all collapse to `fanin`. +/// The single rule every `docs`-subject and doc-id lookup matches through, so a +/// metric / principle / doc resolves regardless of separators or case. +pub(crate) fn normalize_id(s: &str) -> String { + s.chars() + .filter(|c| c.is_ascii_alphanumeric()) + .map(|c| c.to_ascii_lowercase()) + .collect() +} + +/// The base-corpus doc stem a metric key maps to, matched on the normalized form +/// (so `hk`→`HK`, `fan_in`→`Fan-in`). `None` when the metric ships no prose doc +/// (e.g. `sloc`, `eta1`). Replaces the old "parse the doc id out of the metric's +/// remediation prose" — the mapping is the corpus itself, not a stored string. +pub(crate) fn metric_doc_stem(key: &str) -> Option<&'static str> { + let want = normalize_id(key); + CORPUS.iter().find_map(|(rel, _)| { + let stem = rel.strip_prefix("base/")?.strip_suffix(".md")?; + (normalize_id(stem) == want).then_some(stem) + }) +} + +/// The corpus path a doc id resolves to, as `<lang>/<ID>.md`. In order: a matching +/// principle's already-resolved `doc_url`; a metric key whose corpus doc is found by +/// [`metric_doc_stem`] (honoring a language's `<lang>/` override); else any base doc +/// addressable by its (normalized) filename stem. All matching is separator- and +/// case-insensitive (see [`normalize_id`]). +fn doc_rel_path( + principles: &[Principle], + node_attributes: &BTreeMap<String, AttributeSpec>, + id: &str, +) -> Option<String> { // `cycle` is the ADP principle's metric lens (not a real node attribute), so // its doc IS the ADP doc — resolve it through the ADP principle below. - let id = if id.eq_ignore_ascii_case("cycle") { + let id = if normalize_id(id) == "cycle" { "ADP" } else { id }; - // Principle (case-insensitive id, e.g. `SRP`, `adp`). - if let Some(p) = snap - .principles + // Principle (normalized id, e.g. `SRP`, `adp`). + if let Some(p) = principles .iter() - .find(|p| p.id.eq_ignore_ascii_case(id)) + .find(|p| normalize_id(&p.id) == normalize_id(id)) && let Some(url) = &p.doc_url && let Some(rel) = url_tail(url) { return Some(rel); } - // Metric doc: the attribute is found by its (lowercase) key, but the canonical - // doc filename comes from the `--doc <ID>` token in its `remediation` (e.g. key - // `fan_in` → doc `Fan-in.md`). Metric docs live in the neutral `base/` corpus. + // Metric doc: a node attribute whose key maps to a corpus doc (`hk`→`HK`, + // `fan_in`→`Fan-in`). Metric docs default to the neutral `base/` corpus but + // honor a language's doc override exactly as principle docs do — if the plugin + // routes its principle docs to a `<lang>/` folder and ships a `<lang>/<doc>.md`, + // serve that; otherwise the shared `base/`. let key = id.to_ascii_lowercase(); - if let Some(doc) = snap - .graphs - .get("files") - .and_then(|f| f.node_attributes.get(&key)) - .and_then(|spec| spec.remediation.as_deref()) - .and_then(crate::recommend::doc_ref) + if node_attributes.contains_key(&key) + && let Some(stem) = metric_doc_stem(&key) { - // Metric docs default to the neutral `base/` corpus, but honor a language's - // doc override exactly as principle docs do: if the plugin routes its - // principle docs to a `<lang>/` folder (via `doc_overrides`) and actually - // ships a `<lang>/<doc>.md`, serve that; otherwise the shared `base/`. - let lang_doc = override_lang(snap) - .map(|lang| format!("{lang}/{doc}.md")) + let lang_doc = override_lang(principles) + .map(|lang| format!("{lang}/{stem}.md")) .filter(|rel| corpus_doc(rel).is_some()); - return Some(lang_doc.unwrap_or_else(|| format!("base/{doc}.md"))); + return Some(lang_doc.unwrap_or_else(|| format!("base/{stem}.md"))); } - // Fallback: any base corpus doc addressable by its filename stem - // (case-insensitive) — covers docs that are neither a principle nor a metric: - // `Fan-in` / `Fan-out` (key is `fan_in`, not the hyphenated filename), the - // `metrics` reference, and the `AI` overview index. + // Fallback: any base corpus doc addressable by its filename stem (normalized) — + // covers docs that are neither a principle nor a metric: `Fan-in` / `Fan-out` + // (key is `fan_in`, not the hyphenated filename), the `metrics` reference, and + // the `AI` overview index. + let want = normalize_id(id); CORPUS.iter().find_map(|(rel, _)| { let stem = rel.strip_prefix("base/")?.strip_suffix(".md")?; - stem.eq_ignore_ascii_case(id).then(|| (*rel).to_string()) + (normalize_id(stem) == want).then(|| (*rel).to_string()) }) } @@ -84,8 +106,8 @@ fn url_tail(url: &str) -> Option<String> { /// from the principle `doc_url`s the config already routed through `doc_overrides`; /// `None` when the plugin uses the shared `base/` corpus. Lets metric docs reuse the /// same override decision without re-reading the plugin config here. -fn override_lang(snap: &Snapshot) -> Option<String> { - snap.principles +fn override_lang(principles: &[Principle]) -> Option<String> { + principles .iter() .filter_map(|p| p.doc_url.as_deref()) .filter_map(url_tail) @@ -147,7 +169,7 @@ fn doc_summary(md: &str) -> Option<String> { /// 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 `ai` and `report --doc` stdout paths so the rule lives in one +/// Shared by the `docs` stdout paths so the rule lives in one /// (unit-testable) place rather than being re-implemented at each call site. pub(crate) fn with_trailing_newline(mut md: String) -> String { if !md.ends_with('\n') { @@ -156,12 +178,12 @@ pub(crate) fn with_trailing_newline(mut md: String) -> String { md } -/// One catalog entry: a `### <title>` heading + a `--doc <stem>` pointer, plus the +/// One catalog entry: a `### <title>` 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 report --doc {stem}`"); + let head = format!("### {title}\n\nFull doc: `code-ranker docs {stem}`"); match summary { Some(s) => format!("{head}\n\n{s}"), None => head, @@ -221,19 +243,28 @@ fn strip_select_section(md: &str) -> String { } } -pub(crate) fn resolve_doc( - snap: &Snapshot, +/// Resolve a doc from the principle + metric specs directly — no snapshot, no +/// analysis. Backs the `docs <principle>` / `docs <metric>` paths, where the specs +/// are built from the merged config and the resolved plugin rather than from an +/// analyzed project. +pub(crate) fn resolve_doc_from_specs( + principles: &[Principle], + node_attributes: &BTreeMap<String, AttributeSpec>, templates: &TemplatesConfig, id: &str, ) -> Result<String> { - Ok(expand_tldr_index(&resolve_doc_raw(snap, templates, id)?)) + Ok(expand_tldr_index(&resolve_doc_raw( + principles, + node_attributes, + templates, + id, + )?)) } /// The offline AI-agent overview (`base/AI.md`) with its catalog index expanded — -/// identical to `report --doc AI`, but served straight from the embedded corpus -/// with **no snapshot, no project analysis, and no plugin detection**. Backs the -/// `ai` subcommand, so the playbook prints in any directory regardless of language -/// markers. +/// 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> { let md = corpus_doc("base/AI.md").context("base/AI.md is not embedded in this build")?; Ok(expand_tldr_index(md)) @@ -262,9 +293,14 @@ pub(crate) fn ai_doc_intro() -> Result<String> { /// fallback. /// /// `id` is a principle id (`SRP`) or a metric key (`hk`). -fn resolve_doc_raw(snap: &Snapshot, templates: &TemplatesConfig, id: &str) -> Result<String> { - let rel = doc_rel_path(snap, id).with_context(|| { - let known: Vec<&str> = snap.principles.iter().map(|p| p.id.as_str()).collect(); +fn resolve_doc_raw( + principles: &[Principle], + node_attributes: &BTreeMap<String, AttributeSpec>, + templates: &TemplatesConfig, + id: &str, +) -> Result<String> { + let rel = doc_rel_path(principles, node_attributes, id).with_context(|| { + let known: Vec<&str> = principles.iter().map(|p| p.id.as_str()).collect(); format!( "no principle or metric doc for {id:?}. Known principles: {}", known.join(", ") diff --git a/crates/code-ranker-cli/src/templates_test.rs b/crates/code-ranker-cli/src/templates_test.rs index 8c066771..992bdcbc 100644 --- a/crates/code-ranker-cli/src/templates_test.rs +++ b/crates/code-ranker-cli/src/templates_test.rs @@ -1,10 +1,24 @@ use super::*; use code_ranker_graph::level_graph::LevelGraph; +use code_ranker_graph::snapshot::Snapshot; use code_ranker_plugin_api::Principle; use code_ranker_plugin_api::attrs::ValueType; use code_ranker_plugin_api::level::AttributeSpec; use std::collections::BTreeMap; +/// Test shim mirroring the old snapshot-based `resolve_doc`: pulls the principles +/// and `files`-level node-attribute specs out of a test snapshot and feeds the +/// spec-based core. Keeps these tests reading naturally now that production +/// resolves docs from config/plugin specs (no snapshot) via `docs`. +fn resolve_doc(s: &Snapshot, templates: &TemplatesConfig, id: &str) -> Result<String> { + resolve_doc_from_specs( + &s.principles, + &s.graphs["files"].node_attributes, + templates, + id, + ) +} + /// A snapshot carrying just the bits `resolve_doc`/`doc_rel_path` read: /// the principles and the `files` level's node-attribute specs. fn snap(principles: Vec<Principle>, files_attrs: BTreeMap<String, AttributeSpec>) -> Snapshot { @@ -42,10 +56,10 @@ fn principle(id: &str, doc_url: &str) -> Principle { } } -fn metric_spec(remediation: &str) -> AttributeSpec { - let mut spec = AttributeSpec::new(ValueType::Float, "HK"); - spec.remediation = Some(remediation.to_string()); - spec +fn metric_spec() -> AttributeSpec { + // The doc now resolves from the attribute's key (not a remediation string), so a + // bare spec under the right key is all these tests need. + AttributeSpec::new(ValueType::Float, "HK") } #[test] @@ -154,20 +168,35 @@ fn resolve_doc_cycle_resolves_to_adp() { } #[test] -fn resolve_doc_finds_metric_via_remediation_doc_ref() { - // No matching principle — the doc resolves through the metric's remediation - // `--doc <ID>` reference (the attribute looked up by its lowercased key, the - // canonical doc filename taken from the `--doc` id). Metric docs live in base/. +fn resolve_doc_finds_metric_doc_by_key() { + // No matching principle — the doc resolves through the metric key itself: the + // attribute is present in `node_attributes`, and its base-corpus doc is found by + // normalized stem (`hk`→`HK`, separators/case ignored). Metric docs live in base/. let mut attrs = BTreeMap::new(); - attrs.insert( - "hk".to_string(), - metric_spec("Run `code-ranker report --doc HK` and follow its instructions."), - ); + attrs.insert("hk".to_string(), metric_spec()); let s = snap(vec![], attrs); let doc = resolve_doc(&s, &TemplatesConfig::default(), "HK").unwrap(); assert_eq!(doc, corpus_doc("base/HK.md").unwrap()); } +#[test] +fn normalize_id_collapses_separators_and_case() { + assert_eq!(normalize_id("Fan-in"), "fanin"); + assert_eq!(normalize_id("fan_in"), "fanin"); + assert_eq!(normalize_id("FAN in"), "fanin"); + assert_eq!(normalize_id("HK"), "hk"); +} + +#[test] +fn metric_doc_stem_maps_key_to_corpus_stem() { + // `_`/`-`/case all ignored, so a metric key finds its corpus doc. + assert_eq!(metric_doc_stem("hk"), Some("HK")); + assert_eq!(metric_doc_stem("fan_in"), Some("Fan-in")); + assert_eq!(metric_doc_stem("fan_out"), Some("Fan-out")); + // A metric with no prose doc resolves to nothing. + assert_eq!(metric_doc_stem("sloc"), None); +} + #[test] fn doc_rel_path_serves_lang_override_for_a_metric_doc() { // A metric doc (`hk` → `HK`) is routed to the `<lang>/` corpus when the @@ -176,10 +205,7 @@ fn doc_rel_path_serves_lang_override_for_a_metric_doc() { // 566fb23 (templates.rs line 63). Without a rust-routing principle the same // metric falls back to `base/HK.md` (see the previous test). let mut attrs = BTreeMap::new(); - attrs.insert( - "hk".to_string(), - metric_spec("Run `code-ranker report --doc HK` and follow its instructions."), - ); + attrs.insert("hk".to_string(), metric_spec()); let s = snap( vec![principle( "ADP", @@ -187,7 +213,11 @@ fn doc_rel_path_serves_lang_override_for_a_metric_doc() { )], attrs, ); - assert_eq!(doc_rel_path(&s, "HK"), Some("rust/HK.md".to_string())); + let na = &s.graphs["files"].node_attributes; + assert_eq!( + doc_rel_path(&s.principles, na, "HK"), + Some("rust/HK.md".to_string()) + ); } #[test] @@ -267,7 +297,7 @@ fn resolve_doc_ai_index_expands_tldr_marker() { "catalog lists ADP" ); assert!( - doc.contains("Full doc: `code-ranker report --doc ADP`"), + doc.contains("Full doc: `code-ranker docs ADP`"), "each entry points at its --doc id" ); assert!(doc.contains("**TL;DR**"), "entries carry their TL;DR"); @@ -280,7 +310,7 @@ fn resolve_doc_ai_index_expands_tldr_marker() { #[test] fn ai_doc_matches_resolve_doc_and_needs_no_snapshot() { // `ai_doc()` backs the project-free `ai` subcommand: it must produce exactly - // what `report --doc AI` does, but without a snapshot or plugin. + // what `docs AI` does, but without a snapshot or plugin. let doc = ai_doc().unwrap(); let via_resolve = resolve_doc( &snap(vec![], BTreeMap::new()), @@ -288,7 +318,7 @@ fn ai_doc_matches_resolve_doc_and_needs_no_snapshot() { "AI", ) .unwrap(); - assert_eq!(doc, via_resolve, "ai_doc == report --doc AI output"); + assert_eq!(doc, via_resolve, "ai_doc == docs AI output"); assert!( doc.contains("code-ranker — AI agent skill"), "overview head" @@ -325,14 +355,14 @@ fn ai_doc_matches_resolve_doc_and_needs_no_snapshot() { fn ai_doc_intro_keeps_description_and_commands_but_not_the_playbook() { let intro = ai_doc_intro().unwrap(); assert!( - intro.contains("code-ranker — AI agent skill") && intro.contains("**TL;DR**"), + intro.contains("code-ranker — AI agent skill"), "intro keeps the title + product description" ); assert!( intro.contains("## Commands") && intro.contains("**`check") && intro.contains("**`report") - && intro.contains("**`ai`**") + && intro.contains("**`docs") && intro.contains("**`help`**"), "intro lists the main commands: {intro}" ); @@ -391,7 +421,7 @@ fn catalog_entry_includes_summary_when_present_and_omits_when_absent() { "heading first: {with}" ); assert!( - with.contains("Full doc: `code-ranker report --doc HK`"), + with.contains("Full doc: `code-ranker docs HK`"), "carries the --doc pointer: {with}" ); assert!( @@ -401,10 +431,7 @@ fn catalog_entry_includes_summary_when_present_and_omits_when_absent() { // 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 report --doc EC`" - ); + assert_eq!(without, "### Edge Case\n\nFull doc: `code-ranker docs EC`"); } #[test] diff --git a/crates/code-ranker-cli/tests/e2e.rs b/crates/code-ranker-cli/tests/e2e.rs index 6a219772..c7170c5f 100644 --- a/crates/code-ranker-cli/tests/e2e.rs +++ b/crates/code-ranker-cli/tests/e2e.rs @@ -564,19 +564,8 @@ fn rust_sample_prompt_flag_targets_metric_lens() { ); } -/// `--prompt` and `--doc` are mutually exclusive (the standalone-output guard). -#[test] -fn rust_sample_report_rejects_prompt_with_doc() { - let (ok, _stdout, stderr) = run_report_capture("rust", &["--prompt", "HK", "--doc", "SRP"]); - assert!(!ok, "mutually-exclusive flags must error"); - assert!( - stderr.contains("--prompt and --doc are mutually exclusive"), - "names the conflict: {stderr}" - ); -} - -/// `ai` with **no resolvable plugin** (an empty directory — no markers) exits `0` -/// and prints the brief intro plus how to select a plugin, **withholding** the +/// `docs ai` with **no resolvable plugin** (an empty directory — no markers) exits +/// `0` and prints the brief intro plus how to select a plugin, **withholding** the /// principle/metric catalog until a language is chosen. #[test] fn ai_unresolved_omits_catalog_and_shows_plugin_setup() { @@ -585,12 +574,12 @@ fn ai_unresolved_omits_catalog_and_shows_plugin_setup() { std::fs::create_dir_all(&dir).unwrap(); let res = Command::new(env!("CARGO_BIN_EXE_code-ranker")) .current_dir(&dir) - .arg("ai") + .args(["docs", "ai"]) .output() - .expect("spawn ai"); + .expect("spawn docs ai"); assert!( res.status.success(), - "ai must exit 0 even with no plugin: {}", + "docs ai must exit 0 even with no plugin: {}", String::from_utf8_lossy(&res.stderr) ); let stdout = String::from_utf8_lossy(&res.stdout); @@ -602,7 +591,7 @@ fn ai_unresolved_omits_catalog_and_shows_plugin_setup() { stdout.contains("## Commands") && stdout.contains("**`help`**") && stdout.contains("**`report"), - "lists the main commands (check/report/ai/help)" + "lists the main commands (check/report/docs/help)" ); assert!( stdout.contains("## Select a language") && stdout.contains("--plugin"), @@ -621,18 +610,18 @@ fn ai_unresolved_omits_catalog_and_shows_plugin_setup() { ); } -/// `ai` run inside a project whose plugin **auto-detects** (the Rust sample) prints -/// the full playbook + catalog and never mentions plugin setup. +/// `docs ai` run inside a project whose plugin **auto-detects** (the Rust sample) +/// prints the full playbook + catalog and never mentions plugin setup. #[test] fn ai_resolved_prints_full_catalog_without_setup() { let res = Command::new(env!("CARGO_BIN_EXE_code-ranker")) .current_dir(sample_dir("rust")) - .arg("ai") + .args(["docs", "ai"]) .output() - .expect("spawn ai"); + .expect("spawn docs ai"); assert!( res.status.success(), - "ai failed: {}", + "docs ai failed: {}", String::from_utf8_lossy(&res.stderr) ); let stdout = String::from_utf8_lossy(&res.stdout); @@ -841,23 +830,62 @@ fn rust_sample_output_prompt_focus_principle_targets_it() { ); } -/// `report --doc <ID>` prints the embedded corpus Markdown for a principle/metric -/// directly. `HK` is a metric (its doc lives in `base/`, reached via the metric's -/// remediation URL), exercising the metric-doc resolution path. +/// `docs <ID>` prints a reference doc with no analysis. A metric (`hk`) renders its +/// spec card and then appends the embedded corpus doc (reached via the metric's +/// remediation reference); a principle (`SRP`) prints its full corpus doc. #[test] -fn rust_sample_doc_flag_prints_embedded_markdown() { - let (ok, stdout, stderr) = run_report_capture("rust", &["--doc", "HK"]); - assert!(ok, "--doc run failed: {stderr}"); +fn rust_sample_docs_subject_prints_embedded_markdown() { + let run = |subject: &str| -> (bool, String) { + let res = Command::new(env!("CARGO_BIN_EXE_code-ranker")) + .current_dir(sample_dir("rust")) + .args(["docs", subject]) + .output() + .expect("spawn docs"); + ( + res.status.success(), + String::from_utf8_lossy(&res.stdout).into_owned(), + ) + }; + let (ok, stdout) = run("hk"); + assert!(ok, "docs hk failed"); assert!( - stdout.starts_with("# HK — Henry–Kafura"), - "embedded HK doc printed: {stdout}" + stdout.starts_with("# hk: Henry–Kafura"), + "metric spec card first: {stdout}" + ); + assert!( + stdout.contains("# HK — Henry–Kafura"), + "embedded HK doc appended after the card: {stdout}" ); // A principle id resolves too (SRP → its own rust/ corpus doc). - let (ok2, stdout2, _) = run_report_capture("rust", &["--doc", "SRP"]); + let (ok2, stdout2) = run("SRP"); assert!( ok2 && stdout2.contains("Single Responsibility"), "SRP doc: {stdout2}" ); + + // A metric category prints its label + member metrics. + let (ok3, stdout3) = run("loc"); + assert!( + ok3 && stdout3.contains("loc: Lines of Code") && stdout3.contains("- sloc:"), + "category listing: {stdout3}" + ); + + // Subjects match separator/case-insensitively: `FAN out` resolves `fan_out`. + let (ok_norm, stdout_norm) = run("FAN out"); + assert!( + ok_norm && stdout_norm.starts_with("# fan_out: Fan-out"), + "normalized subject resolves the metric: {stdout_norm}" + ); + + // An unknown subject prints the catalog and exits non-zero. + let (ok4, stdout4) = run("nope"); + assert!(!ok4, "unknown subject must exit non-zero"); + assert!( + stdout4.contains("Unknown docs subject `nope`") + && stdout4.contains("principles: Design principles") + && stdout4.contains("Call `docs`"), + "catalog shown for an unknown subject: {stdout4}" + ); } /// `--focus <metric>` frames the scorecard by that metric. `--focus cycle` diff --git a/crates/code-ranker-graph/metrics/builtin.toml b/crates/code-ranker-graph/metrics/builtin.toml index acbb8b88..d10f50ab 100644 --- a/crates/code-ranker-graph/metrics/builtin.toml +++ b/crates/code-ranker-graph/metrics/builtin.toml @@ -137,7 +137,6 @@ That nesting penalty is the point — deeply indented logic is what actually str Summed across every function in the file.""" direction = "lower_better" category = "complexity" -remediation = "Run `code-ranker report --doc Cognitive` and follow its instructions." [ast.exits] value_type = "int" @@ -228,7 +227,6 @@ formula_js = "spaces + branches" direction = "lower_better" category = "complexity" omit_at = 1.0 -remediation = "Run `code-ranker report --doc Cyclomatic` and follow its instructions." [fields.effort] value_type = "float" @@ -331,7 +329,6 @@ formula_js = "sloc * (fan_in * fan_out) ** 2" direction = "lower_better" category = "coupling" abbreviate = true -remediation = "Run `code-ranker report --doc HK` and follow its instructions." # ── coupling (computed post-walk by annotate_coupling / annotate_cycles) ─────── # Spec-only entries: the VALUES are derived by the graph crate's coupling/cycle @@ -344,14 +341,12 @@ value_type = "int" label = "Fan-in" description = "Many other units depend on this one, making it risky to change and a single point of failure — though some hubs (shared types) carry high fan-in legitimately." category = "coupling" -remediation = "Run `code-ranker report --doc Fan-in` and follow its instructions." [coupling.fan_out] value_type = "int" label = "Fan-out" description = "This unit depends on many others, so it breaks when any of them change and is hard to test in isolation." category = "coupling" -remediation = "Run `code-ranker report --doc Fan-out` and follow its instructions." [coupling.fan_out_external] value_type = "int" @@ -372,12 +367,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 report --doc ADP` and follow its instructions." +remediation = "Run `code-ranker docs 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 report --doc ADP` and follow its instructions." +remediation = "Run `code-ranker docs 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 dbf0e305..17796469 100644 --- a/crates/code-ranker-graph/metrics/prompt.md +++ b/crates/code-ranker-graph/metrics/prompt.md @@ -15,7 +15,7 @@ I want to apply this to some modules in my system. ## doc_note -**First, before reading the source**, run `code-ranker report --doc {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 {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 34b9822a..cbf4e30c 100644 --- a/crates/code-ranker-graph/src/builtin_test.rs +++ b/crates/code-ranker-graph/src/builtin_test.rs @@ -12,7 +12,7 @@ fn prompt_template_parses_from_markdown() { "I want to apply this to some modules in my system." ); assert!( - t.doc_note.contains("`code-ranker report --doc {id}`"), + t.doc_note.contains("`code-ranker docs {id}`"), "doc_note points at the offline --doc command: {:?}", t.doc_note ); 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 dc9baf60..7d7a1b70 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 @@ -126,7 +126,6 @@ "group": "complexity", "label": "Cognitive", "name": "Cognitive complexity", - "remediation": "Run `code-ranker report --doc Cognitive` and follow its instructions.", "short": "Cognitive", "value_type": "int" }, @@ -139,7 +138,6 @@ "label": "Cyclomatic", "name": "Cyclomatic complexity", "omit_at": 1.0, - "remediation": "Run `code-ranker report --doc Cyclomatic` and follow its instructions.", "short": "Cyclomatic", "value_type": "int" }, @@ -190,7 +188,6 @@ "group": "coupling", "label": "Fan-in", "name": "Fan-in", - "remediation": "Run `code-ranker report --doc Fan-in` and follow its instructions.", "short": "Fan-in", "value_type": "int" }, @@ -199,7 +196,6 @@ "group": "coupling", "label": "Fan-out", "name": "Fan-out", - "remediation": "Run `code-ranker report --doc Fan-out` and follow its instructions.", "short": "Fan-out", "value_type": "int" }, @@ -220,7 +216,6 @@ "group": "coupling", "label": "HK", "name": "Henry–Kafura", - "remediation": "Run `code-ranker report --doc HK` and follow its instructions.", "short": "HK", "value_type": "float" }, @@ -736,7 +731,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 report --doc {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 {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/cpp/tests/sample/code-ranker-report.json b/crates/code-ranker-plugins/src/languages/cpp/tests/sample/code-ranker-report.json index a547fc1d..7ae388e2 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 @@ -135,7 +135,6 @@ "group": "complexity", "label": "Cognitive", "name": "Cognitive complexity", - "remediation": "Run `code-ranker report --doc Cognitive` and follow its instructions.", "short": "Cognitive", "value_type": "int" }, @@ -148,7 +147,6 @@ "label": "Cyclomatic", "name": "Cyclomatic complexity", "omit_at": 1.0, - "remediation": "Run `code-ranker report --doc Cyclomatic` and follow its instructions.", "short": "Cyclomatic", "value_type": "int" }, @@ -199,7 +197,6 @@ "group": "coupling", "label": "Fan-in", "name": "Fan-in", - "remediation": "Run `code-ranker report --doc Fan-in` and follow its instructions.", "short": "Fan-in", "value_type": "int" }, @@ -208,7 +205,6 @@ "group": "coupling", "label": "Fan-out", "name": "Fan-out", - "remediation": "Run `code-ranker report --doc Fan-out` and follow its instructions.", "short": "Fan-out", "value_type": "int" }, @@ -229,7 +225,6 @@ "group": "coupling", "label": "HK", "name": "Henry–Kafura", - "remediation": "Run `code-ranker report --doc HK` and follow its instructions.", "short": "HK", "value_type": "float" }, @@ -746,7 +741,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 report --doc {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 {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/tests/sample/code-ranker-report.json b/crates/code-ranker-plugins/src/languages/csharp/tests/sample/code-ranker-report.json index 8a78fd2a..30c958cf 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 @@ -123,7 +123,6 @@ "group": "complexity", "label": "Cognitive", "name": "Cognitive complexity", - "remediation": "Run `code-ranker report --doc Cognitive` and follow its instructions.", "short": "Cognitive", "value_type": "int" }, @@ -136,7 +135,6 @@ "label": "Cyclomatic", "name": "Cyclomatic complexity", "omit_at": 1.0, - "remediation": "Run `code-ranker report --doc Cyclomatic` and follow its instructions.", "short": "Cyclomatic", "value_type": "int" }, @@ -187,7 +185,6 @@ "group": "coupling", "label": "Fan-in", "name": "Fan-in", - "remediation": "Run `code-ranker report --doc Fan-in` and follow its instructions.", "short": "Fan-in", "value_type": "int" }, @@ -196,7 +193,6 @@ "group": "coupling", "label": "Fan-out", "name": "Fan-out", - "remediation": "Run `code-ranker report --doc Fan-out` and follow its instructions.", "short": "Fan-out", "value_type": "int" }, @@ -217,7 +213,6 @@ "group": "coupling", "label": "HK", "name": "Henry–Kafura", - "remediation": "Run `code-ranker report --doc HK` and follow its instructions.", "short": "HK", "value_type": "float" }, @@ -682,7 +677,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 report --doc {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 {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 f16a2ba7..c047feb1 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 @@ -123,7 +123,6 @@ "group": "complexity", "label": "Cognitive", "name": "Cognitive complexity", - "remediation": "Run `code-ranker report --doc Cognitive` and follow its instructions.", "short": "Cognitive", "value_type": "int" }, @@ -136,7 +135,6 @@ "label": "Cyclomatic", "name": "Cyclomatic complexity", "omit_at": 1.0, - "remediation": "Run `code-ranker report --doc Cyclomatic` and follow its instructions.", "short": "Cyclomatic", "value_type": "int" }, @@ -187,7 +185,6 @@ "group": "coupling", "label": "Fan-in", "name": "Fan-in", - "remediation": "Run `code-ranker report --doc Fan-in` and follow its instructions.", "short": "Fan-in", "value_type": "int" }, @@ -196,7 +193,6 @@ "group": "coupling", "label": "Fan-out", "name": "Fan-out", - "remediation": "Run `code-ranker report --doc Fan-out` and follow its instructions.", "short": "Fan-out", "value_type": "int" }, @@ -217,7 +213,6 @@ "group": "coupling", "label": "HK", "name": "Henry–Kafura", - "remediation": "Run `code-ranker report --doc HK` and follow its instructions.", "short": "HK", "value_type": "float" }, @@ -684,7 +679,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 report --doc {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 {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/javascript/tests/sample/code-ranker-report.json b/crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker-report.json index b57e4964..8b78e0aa 100644 --- a/crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker-report.json +++ b/crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker-report.json @@ -36,12 +36,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 report --doc ADP` and follow its instructions." + "remediation": "Run `code-ranker docs ADP` and follow its instructions." }, "mutual": { "description": "Two units import each other (A ↔ B), so neither can be built, tested, or understood in isolation — the tightest possible coupling.", "label": "Mutual", - "remediation": "Run `code-ranker report --doc ADP` and follow its instructions." + "remediation": "Run `code-ranker docs ADP` and follow its instructions." } }, "cycles": [ @@ -198,7 +198,6 @@ "group": "complexity", "label": "Cognitive", "name": "Cognitive complexity", - "remediation": "Run `code-ranker report --doc Cognitive` and follow its instructions.", "short": "Cognitive", "value_type": "int" }, @@ -218,7 +217,6 @@ "label": "Cyclomatic", "name": "Cyclomatic complexity", "omit_at": 1.0, - "remediation": "Run `code-ranker report --doc Cyclomatic` and follow its instructions.", "short": "Cyclomatic", "value_type": "int" }, @@ -269,7 +267,6 @@ "group": "coupling", "label": "Fan-in", "name": "Fan-in", - "remediation": "Run `code-ranker report --doc Fan-in` and follow its instructions.", "short": "Fan-in", "value_type": "int" }, @@ -278,7 +275,6 @@ "group": "coupling", "label": "Fan-out", "name": "Fan-out", - "remediation": "Run `code-ranker report --doc Fan-out` and follow its instructions.", "short": "Fan-out", "value_type": "int" }, @@ -299,7 +295,6 @@ "group": "coupling", "label": "HK", "name": "Henry–Kafura", - "remediation": "Run `code-ranker report --doc HK` and follow its instructions.", "short": "HK", "value_type": "float" }, @@ -925,7 +920,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 report --doc {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 {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/markdown/tests/sample/code-ranker-report.json b/crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker-report.json index ba5d305a..6ee928dd 100644 --- a/crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker-report.json +++ b/crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker-report.json @@ -20,7 +20,7 @@ "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 report --doc ADP` and follow its instructions." + "remediation": "Run `code-ranker docs ADP` and follow its instructions." } }, "cycles": [ @@ -98,7 +98,6 @@ "group": "coupling", "label": "Fan-in", "name": "Fan-in", - "remediation": "Run `code-ranker report --doc Fan-in` and follow its instructions.", "short": "Fan-in", "value_type": "int" }, @@ -107,7 +106,6 @@ "group": "coupling", "label": "Fan-out", "name": "Fan-out", - "remediation": "Run `code-ranker report --doc Fan-out` and follow its instructions.", "short": "Fan-out", "value_type": "int" }, @@ -231,7 +229,7 @@ "plugin": "markdown", "prompt": { "cycle_note": "This is **one** dependency cycle; every module in it is listed below so the whole loop is visible. Fix one cycle at a time — `--top 2`+ lists several separate cycles at once and obscures how each one connects.", - "doc_note": "**First, before reading the source**, run `code-ranker report --doc {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 {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 d42c711b..28db4664 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 @@ -36,12 +36,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 report --doc ADP` and follow its instructions." + "remediation": "Run `code-ranker docs ADP` and follow its instructions." }, "mutual": { "description": "Two units import each other (A ↔ B), so neither can be built, tested, or understood in isolation — the tightest possible coupling.", "label": "Mutual", - "remediation": "Run `code-ranker report --doc ADP` and follow its instructions." + "remediation": "Run `code-ranker docs ADP` and follow its instructions." } }, "cycles": [ @@ -252,7 +252,6 @@ "group": "complexity", "label": "Cognitive", "name": "Cognitive complexity", - "remediation": "Run `code-ranker report --doc Cognitive` and follow its instructions.", "short": "Cognitive", "value_type": "int" }, @@ -272,7 +271,6 @@ "label": "Cyclomatic", "name": "Cyclomatic complexity", "omit_at": 1.0, - "remediation": "Run `code-ranker report --doc Cyclomatic` and follow its instructions.", "short": "Cyclomatic", "value_type": "int" }, @@ -323,7 +321,6 @@ "group": "coupling", "label": "Fan-in", "name": "Fan-in", - "remediation": "Run `code-ranker report --doc Fan-in` and follow its instructions.", "short": "Fan-in", "value_type": "int" }, @@ -332,7 +329,6 @@ "group": "coupling", "label": "Fan-out", "name": "Fan-out", - "remediation": "Run `code-ranker report --doc Fan-out` and follow its instructions.", "short": "Fan-out", "value_type": "int" }, @@ -353,7 +349,6 @@ "group": "coupling", "label": "HK", "name": "Henry–Kafura", - "remediation": "Run `code-ranker report --doc HK` and follow its instructions.", "short": "HK", "value_type": "float" }, @@ -1034,7 +1029,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 report --doc {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 {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 333c79b3..50ff07be 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 @@ -36,12 +36,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 report --doc ADP` and follow its instructions." + "remediation": "Run `code-ranker docs ADP` and follow its instructions." }, "mutual": { "description": "Two units import each other (A ↔ B), so neither can be built, tested, or understood in isolation — the tightest possible coupling.", "label": "Mutual", - "remediation": "Run `code-ranker report --doc ADP` and follow its instructions." + "remediation": "Run `code-ranker docs ADP` and follow its instructions." } }, "cycles": [ @@ -421,7 +421,6 @@ "group": "complexity", "label": "Cognitive", "name": "Cognitive complexity", - "remediation": "Run `code-ranker report --doc Cognitive` and follow its instructions.", "short": "Cognitive", "value_type": "int" }, @@ -445,7 +444,6 @@ "label": "Cyclomatic", "name": "Cyclomatic complexity", "omit_at": 1.0, - "remediation": "Run `code-ranker report --doc Cyclomatic` and follow its instructions.", "short": "Cyclomatic", "value_type": "int" }, @@ -501,7 +499,6 @@ "group": "coupling", "label": "Fan-in", "name": "Fan-in", - "remediation": "Run `code-ranker report --doc Fan-in` and follow its instructions.", "short": "Fan-in", "value_type": "int" }, @@ -510,7 +507,6 @@ "group": "coupling", "label": "Fan-out", "name": "Fan-out", - "remediation": "Run `code-ranker report --doc Fan-out` and follow its instructions.", "short": "Fan-out", "value_type": "int" }, @@ -531,7 +527,6 @@ "group": "coupling", "label": "HK", "name": "Henry–Kafura", - "remediation": "Run `code-ranker report --doc HK` and follow its instructions.", "short": "HK", "value_type": "float" }, @@ -1722,7 +1717,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 report --doc {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 {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/typescript/tests/sample/code-ranker-report.json b/crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker-report.json index c1c110ad..ee69ba74 100644 --- a/crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker-report.json +++ b/crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker-report.json @@ -36,12 +36,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 report --doc ADP` and follow its instructions." + "remediation": "Run `code-ranker docs ADP` and follow its instructions." }, "mutual": { "description": "Two units import each other (A ↔ B), so neither can be built, tested, or understood in isolation — the tightest possible coupling.", "label": "Mutual", - "remediation": "Run `code-ranker report --doc ADP` and follow its instructions." + "remediation": "Run `code-ranker docs ADP` and follow its instructions." } }, "cycles": [ @@ -204,7 +204,6 @@ "group": "complexity", "label": "Cognitive", "name": "Cognitive complexity", - "remediation": "Run `code-ranker report --doc Cognitive` and follow its instructions.", "short": "Cognitive", "value_type": "int" }, @@ -224,7 +223,6 @@ "label": "Cyclomatic", "name": "Cyclomatic complexity", "omit_at": 1.0, - "remediation": "Run `code-ranker report --doc Cyclomatic` and follow its instructions.", "short": "Cyclomatic", "value_type": "int" }, @@ -275,7 +273,6 @@ "group": "coupling", "label": "Fan-in", "name": "Fan-in", - "remediation": "Run `code-ranker report --doc Fan-in` and follow its instructions.", "short": "Fan-in", "value_type": "int" }, @@ -284,7 +281,6 @@ "group": "coupling", "label": "Fan-out", "name": "Fan-out", - "remediation": "Run `code-ranker report --doc Fan-out` and follow its instructions.", "short": "Fan-out", "value_type": "int" }, @@ -305,7 +301,6 @@ "group": "coupling", "label": "HK", "name": "Henry–Kafura", - "remediation": "Run `code-ranker report --doc HK` and follow its instructions.", "short": "HK", "value_type": "float" }, @@ -992,7 +987,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 report --doc {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 {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/docs/PRD.md b/docs/PRD.md index 5e064544..11cc954c 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -380,7 +380,8 @@ workspaces. The plugin MUST: The recommendation catalog is the shared 13 design principles (from `defaults.toml`); each coupling/complexity **metric** carries its own fix-prompt doc under `languages/base/` (`HK`, `Fan-in`, `Fan-out`, `Cognitive`, - `Cyclomatic`; the cycle metric reuses `ADP`), referenced from the metric's `remediation` + `Cyclomatic`; the cycle metric reuses `ADP`), resolved from the metric key + (`hk`→`HK`, `fan_in`→`Fan-in`) — separator/case-insensitive **Rationale**: Rust is the primary use-case for the initial release. The `rust` module of the `code-ranker-plugins` crate (cargo metadata + `syn`, diff --git a/docs/ai-skill.md b/docs/ai-skill.md index b5356c9e..72759b49 100644 --- a/docs/ai-skill.md +++ b/docs/ai-skill.md @@ -25,10 +25,10 @@ platform notes): [installation.md](installation.md). - **`report`** — produces artifacts: a JSON snapshot, an HTML viewer, and the advisory **`scorecard`** (console triage) / **`prompt`** (LLM prompt). Always exits `0`. -- **`ai`** — prints this playbook to the terminal (no analysis; always exits `0`). - Run `code-ranker ai` to bootstrap: with a language plugin resolved it prints the - full playbook + principle/metric catalog; with none (no/ambiguous markers) it - prints a brief intro and how to select one. +- **`docs <subject>`** — prints a reference doc to the terminal (no analysis; always + exits `0`). Run `code-ranker docs ai` to bootstrap this playbook: with a language + plugin resolved it prints the full playbook + principle/metric catalog; with none + (no/ambiguous markers) it prints a brief intro and how to select one. `[input]` is polymorphic: a directory is analyzed; a `.json` snapshot is read back with no re-analysis. Keep old `.code-ranker/` snapshots — they are baselines. @@ -36,7 +36,7 @@ back with no re-analysis. Keep old `.code-ranker/` snapshots — they are baseli `check` / `report` analyze one language, auto-detected from project markers. If a directory has markers for several (e.g. Rust + Markdown), they stop with *"ambiguous project … pass --plugin to choose"*: name the language with `--plugin <name>`, or set -`plugin = "<name>"` in a `code-ranker.toml` at the project root. (`ai` never needs this.) +`plugin = "<name>"` in a `code-ranker.toml` at the project root. (`docs` never needs this.) ## The two metrics that matter @@ -47,7 +47,7 @@ Focus on these; treat everything else as secondary. module on a busy crossroads of incoming/outgoing dependencies. Full diagnose-and-split workflow (measure one file, list its fan_in/fan_out, find the mixed scenarios, split, verify with a before/after diff report): run - `code-ranker report --doc HK` (prints the full principle to the terminal, offline). + `code-ranker docs HK` (prints the full principle to the terminal, offline). **Strategy:** fix one thing at a time, worst-first. Cycles (ADP) are structural — clear them first; then coupling (HK). Focus on one metric or principle with `--focus` and inspect diff --git a/docs/code-ranker-cli/CLI.md b/docs/code-ranker-cli/CLI.md index ca54b1ce..6faeee83 100644 --- a/docs/code-ranker-cli/CLI.md +++ b/docs/code-ranker-cli/CLI.md @@ -24,14 +24,14 @@ exact command per entry (triage, CI gates, focused checks, baselines, AI prompts |---|---| | [`check`](#check) | A **verdict**: evaluates thresholds, cycle rules, and (with `--baseline`) regressions, prints diagnostics, and **exits non-zero** on violation. Writes no files. | | [`report`](#report) | **Artifacts**: an HTML viewer and/or a JSON snapshot. With `--baseline`, the HTML becomes a diff with a verdict. Can also emit a console **scorecard** triage and an AI **prompt** (see [Recommendations](#recommendations-scorecard--prompt)). Always exits `0`. | -| [`ai`](#ai) | The offline **AI-agent playbook** to stdout. Never analyzes, always exits `0`. With a resolvable language plugin it prints the full playbook + principle/metric catalog; with none (no marker, or ambiguous markers) it prints a brief intro and how to select one. | +| [`docs`](#docs) | A reference doc for a `<subject>` to stdout. Never analyzes, always exits `0` (an unknown subject exits non-zero). Resolves a language plugin (explicit `--plugin` > the `plugin` config key > none) to choose what to print; serves the AI playbook (`docs ai`), metric/principle indexes, category and metric spec cards, and full principle docs. | There are two analysis commands, split by *what they emit*: `check` produces an exit code (a CI gate), `report` produces files (a snapshot and a viewer). Both take the same -input and share the same vocabulary below. A third command, `ai`, reads no project and -just prints the embedded agent playbook. (The principle/metric doc corpus is not -published — it is embedded in the binary and printed on demand with `report --doc <ID>` -or, for the overview, `code-ranker ai`; see [templates.md](../templates.md).) +input and share the same vocabulary below. A third command, `docs`, reads no project and +just prints a reference doc for the `<subject>` you name. (The principle/metric doc corpus +is not published — it is embedded in the binary and printed on demand with `docs <ID>`; +see [templates.md](../templates.md).) ## Global options @@ -557,7 +557,7 @@ scopes the ranked modules to a subtree. Defaults to the file `.code-ranker/{ts}-{git-hash-3}-{principle}.md` (use `--output.prompt.path=stdout` to pipe it). It is **auto-targeted**: it emits the Markdown fix-prompt for the **single worst module** — its principle's intent and summary, how to -read the full principle (the offline `code-ranker report --doc <id>` command, no network), +read the full principle (the offline `code-ranker docs <id>` command, no network), a task checklist, the offending module annotated with its metric value, and the relevant **flow** connection lists (`uses` — structural `contains`/`reexports` are excluded). The `{principle}` in the default filename is the auto-selected principle id. @@ -573,53 +573,59 @@ code-ranker report . --output.prompt.path=stdout --top 1 code-ranker report . --output.prompt --top 1 ``` -### `--prompt <ID>` / `--doc <ID>` — one principle/metric by name +### `--prompt <ID>` — one principle/metric fix-prompt by name `--prompt <ID>` is the **named** counterpart of `--output.prompt`: it prints that principle/metric's fix-prompt to stdout and exits (shape the module list with `--top N` / -`--focus-path`). `--doc <ID>` prints the **raw principle/metric doc** Markdown (the -resolved `languages/<lang>/<ID>.md`, with any `[templates.languages.…]` override) — offline, -no network. Both accept a principle id (`SRP`, `ADP`) or a metric key (`hk`, `cyclomatic`), -case-insensitive; they are mutually exclusive and write no artifacts. - -`--doc` resolves an id, in order: a principle id, a metric key (the canonical doc -filename comes from the metric's `remediation`, e.g. key `fan_in` → `Fan-in.md`), -then **any base doc by its filename stem** — so `--doc Fan-in`, `--doc metrics` -(the LOC-counting reference) and `--doc AI` all work. `--doc cycle` resolves to the -ADP doc (cycle is ADP's metric lens). **`--doc AI`** prints an AI-agent overview: a -short playbook plus a catalog of every principle/metric with its one-paragraph -TL;DR (auto-assembled from each base doc — see `templates.rs::tldr_index`). +`--focus-path`). It accepts a principle id (`SRP`, `ADP`) or a metric key (`hk`, +`cyclomatic`), case-insensitive, and writes no artifacts. ```sh code-ranker report . --prompt HK --top 1 # HK fix-prompt for the worst module -code-ranker report . --doc HK # the full HK principle text, to stdout -code-ranker report . --doc AI # AI playbook + the principle/metric catalog ``` -For the AI overview, prefer the [`ai`](#ai) command — instead of erroring on an -ambiguous project it prints the playbook, adapting to whether a plugin resolves. +To print a **reference doc** itself (a principle's text, a metric's spec card, the AI +playbook, …) rather than a fix-prompt, use the analysis-free [`docs`](#docs) command — +e.g. `code-ranker docs HK` or `code-ranker docs ai`. -## `ai` +## `docs` -`code-ranker ai` prints the offline AI-agent playbook (from the embedded -`base/AI.md`) to stdout, then exits `0`. It **never analyzes** — it only resolves -which language plugin applies (explicit `--plugin` > the `plugin` config key > -auto-detect from `[input]`'s markers, default `.`) to choose what to print: +``` +code-ranker docs <subject> [--plugin <name|auto>] [--config <PATH|KEY=VALUE>] +``` -- **plugin resolved** → the full playbook **plus** the principle/metric catalog (the - TL;DR index expanded) — the project-free equivalent of `report --doc AI`. It does - not mention plugin setup; the language is already known. -- **no plugin resolvable** (no project marker, or markers for more than one language) - → a brief product intro **plus** a *Select a language* section explaining how to - choose one (`--plugin <name>`, or the `plugin` key in `code-ranker.toml`) and - listing the built-ins. The catalog is **withheld** until a language is chosen. +`code-ranker docs <subject>` prints a reference doc to stdout, then exits `0`. It **never +analyzes** and takes **no `[input]` positional** — config is auto-discovered from the +current directory, and `--plugin` (explicit `--plugin` > the `plugin` config key > none) +resolves which language's docs to serve. An unknown subject exits non-zero. -So `ai` always succeeds — even where `report` / `check` would stop with *"ambiguous -project … pass --plugin to choose"* — and tells the user how to proceed. +`<subject>` selects what to print: + +| `<subject>` | What it prints | +|---|---| +| `ai` | The offline **AI-agent playbook** (from the embedded `base/AI.md`). With a plugin resolved → the full playbook **plus** the principle/metric catalog; with none → a brief intro and how to pick a plugin. | +| `metrics` | An **index of every metric**, grouped by category. | +| `principles` | An **index of every design principle**. | +| a metric **category** (`loc`, `complexity`, `halstead`, `maintainability`, `coupling`) | The category's label/description **plus** its member metrics. | +| a **metric** key (`sloc`, `hk`, …) | The metric's **spec card** (label / name / description / category / formula). For metrics with a full prose doc (`hk`, `cyclomatic`, `cognitive`, `fan_in`, `fan_out`) the prose doc is appended after the card. | +| a **principle** id (`SRP`, `ADP`, … including project-defined `[principles.<ID>]`) | The principle's **full doc** (or a synthetic card for a doc-less custom principle). | +| *(none, or an unknown subject)* | A **catalog of every subject**. No subject exits `0`; an unknown subject exits non-zero. | + +`docs ai` always succeeds — even where `report` / `check` would stop with *"ambiguous +project … pass --plugin to choose"*: with a plugin resolved it prints the full playbook + +catalog (the full project-free playbook); with none it prints a +brief product intro **plus** a *Select a language* section (how to choose one with +`--plugin <name>` or the `plugin` key in `code-ranker.toml`, and the built-ins), withholding +the catalog until a language is chosen. ```sh -code-ranker ai # auto-detect: full playbook, or how to pick a plugin -code-ranker ai --plugin rust # force a language → the full playbook + catalog +code-ranker docs # the catalog of every subject +code-ranker docs ai # auto-detect: full playbook, or how to pick a plugin +code-ranker docs ai --plugin rust # force a language → the full playbook + catalog +code-ranker docs HK # the full HK principle text, to stdout +code-ranker docs metrics # the metric index, grouped by category +code-ranker docs coupling # the coupling category + its member metrics +code-ranker docs cycle # the ADP doc (cycle is ADP's metric lens) ``` ## `--baseline` (comparison) diff --git a/docs/code-ranker-cli/DESIGN.md b/docs/code-ranker-cli/DESIGN.md index 5eebd5a9..0cc1e4b9 100644 --- a/docs/code-ranker-cli/DESIGN.md +++ b/docs/code-ranker-cli/DESIGN.md @@ -36,17 +36,20 @@ The single user-facing binary `code-ranker`. There is no default command — a bare invocation prints help. `main()` owns two analysis subcommands — `check` and `report` — both taking a single polymorphic positional `[input]` (a directory to **analyze**, or a `.json`/`.html` snapshot to **read**, via -`analyze_input` → `is_snapshot_input`); a third `ai` subcommand (`ai.rs`) runs **no -analysis** — it resolves the language plugin (`plugin::resolve_plugin`) only to pick -the output and prints the embedded `base/AI.md` playbook to stdout (full playbook + -catalog when a plugin resolves; a brief intro + how to select one when none does): +`analyze_input` → `is_snapshot_input`); a third `docs` subcommand (`docs.rs`) runs **no +analysis** and takes **no `[input]`** — it resolves the language plugin +(`plugin::resolve_plugin`) only to pick the language, then prints the reference doc for +the requested `<subject>` to stdout (the `ai` playbook, a metric/principle index, a +category or metric spec card, or a principle's full doc; for `docs ai`, the full +playbook + catalog when a plugin resolves, a brief intro + how to select one when +none does): The binary is decomposed by concern — `main()` only parses and dispatches: `cli.rs` (the clap argument model), `analyze.rs` (input dispatch, the snapshot path, and snapshot loading), `pipeline.rs` (the directory-analysis pipeline + `LevelGraph` assembly, owning the `Analyzed` result), `check.rs` (`run_check`), -`report.rs` (`run_report`), `recommend.rs` (prompt/scorecard), `ai.rs` (the `ai` -playbook command), and the `config/` +`report.rs` (`run_report`), `recommend.rs` (prompt/scorecard), `docs.rs` (the `docs` +reference command), and the `config/` module (`model` / `load` / `ignore` / `rules` / `violations`, re-exported through its `mod.rs` facade). `pipeline.rs` concentrates the high fan-out orchestration behind a single caller (`analyze_input`), keeping every file's Henry-Kafura HK @@ -286,7 +289,7 @@ This section notes the implementation binding. The full CLI surface is documented in [CLI.md](CLI.md). The two analysis commands are `check` (verdict + exit code, no files) and `report` (artifacts); both take a polymorphic `[input]` and accept `--baseline <snapshot>`. The doc corpus is embedded -in the binary and printed on demand via `report --doc <ID>` — there is no separate +in the binary and printed on demand via `docs <ID>` — there is no separate publishing subcommand. ### Snapshots — `code-ranker report --output.json` diff --git a/docs/code-ranker-cli/ERRORS.md b/docs/code-ranker-cli/ERRORS.md index 98783806..d49b751c 100644 --- a/docs/code-ranker-cli/ERRORS.md +++ b/docs/code-ranker-cli/ERRORS.md @@ -82,11 +82,13 @@ the concern groups below (CPX / SIZ / CPL). A threshold is a `value > limit` gat so it suits "lower is better" metrics; an unknown metric name is a config error. The most-used ones (`cyclomatic`, `cognitive`, `hk`, `fan_in`, `fan_out`, `loc`) carry a full why/fix rationale below; the rest report the breach with the same -group and message shape. The `why` / `fix` copy is **data-driven**: it is read -from each metric's `description` / `remediation` spec (the metric catalog -`code-ranker-graph/metrics/builtin.toml` and the per-language configs), and cycle -rules from the `[cycles.*]` catalog — the tables below mirror that data, they do -not define it. +group and message shape. The `why` / `fix` copy is **data-driven**: `why` is each +metric's `description` spec, and `fix` is its `remediation` when one is authored +(a project `[metrics.<key>]` may set a custom fix) — otherwise the built-in metrics +carry no boilerplate and `fix` is auto-derived as a pointer to `code-ranker docs <key>`. +Cycle rules read the `[cycles.*]` catalog. The specs live in +`code-ranker-graph/metrics/builtin.toml` and the per-language configs; the tables +below mirror that data, they do not define it. **Value syntax.** A threshold value accepts `_` digit separators and a `K` / `M` / `G` multiplier suffix (×10³ / ×10⁶ / ×10⁹, case-insensitive): `5K` = 5 000, diff --git a/docs/code-ranker-cli/PRD.md b/docs/code-ranker-cli/PRD.md index 03094f86..385d3263 100644 --- a/docs/code-ranker-cli/PRD.md +++ b/docs/code-ranker-cli/PRD.md @@ -31,12 +31,12 @@ All user-facing operations MUST be accessible through a single binary through an explicit subcommand; there is no default command. There are **two** analysis subcommands, split by *what they emit* — `check` produces an exit code (a CI gate), `report` produces files (a snapshot and a -viewer) — plus a small project-free `ai` command (below): +viewer) — plus a small project-free `docs` command (below): ``` code-ranker check [input] [--plugin <name|auto>] [--baseline <snapshot>] [options] code-ranker report [input] [--plugin <name|auto>] [--baseline <snapshot>] [--output.<fmt>.path <path>] [options] -code-ranker ai [input] [--plugin <name|auto>] # offline agent playbook (no analysis) +code-ranker docs <subject> [--plugin <name|auto>] [--config <PATH|KEY=VALUE>] # reference docs (no analysis) ``` The single positional `[input]` (default `.`) is **polymorphic**: a @@ -57,15 +57,21 @@ snapshot input. always exits `0`. Without `--baseline` the HTML is a single-snapshot viewer; with `--baseline <snapshot>` it becomes a baseline↔current diff view with a verdict, named `…-diff.html`. -- `ai` prints the offline AI-agent playbook (the embedded `base/AI.md`) to stdout - and always exits `0`. It runs **no analysis** — it only resolves which language - plugin applies (explicit `--plugin`, the `plugin` config key, or auto-detection - from `[input]`'s markers) to choose the output: with a plugin resolved it prints - the full playbook **plus** the principle/metric catalog (the project-free - equivalent of `report --doc AI`); with none resolvable (no marker, or markers for - several languages) it prints a brief product intro **plus** how to select a plugin - and **omits** the catalog. So it succeeds even where `report` / `check` would stop - on an ambiguous project, and guides the user to a working setup. +- `docs <subject>` prints a reference doc to stdout and always exits `0` (an unknown + subject exits non-zero). It runs **no analysis** and takes **no `[input]`** — config + is auto-discovered from the current directory, and `--plugin` (explicit, the `plugin` + config key, or none) resolves which language's docs to serve. The `<subject>` selects + the output: `ai` (the offline AI-agent playbook from the embedded `base/AI.md`), + `metrics` / `principles` (the metric / principle index), a metric **category** (`loc`, + `complexity`, `halstead`, `maintainability`, `coupling` → its label + member metrics), + a **metric** key (`sloc`, `hk`, … → its spec card, with the prose doc appended for + `hk` / `cyclomatic` / `cognitive` / `fan_in` / `fan_out`), a **principle** id (`SRP`, + `ADP`, … → its full doc), or no/unknown subject (a catalog of every subject). For + `docs ai`, with a plugin resolved it prints the full playbook **plus** the + principle/metric catalog; with none resolvable it prints a brief product intro + **plus** how to select a plugin and **omits** the catalog. So `docs ai` succeeds even + where `report` / `check` would stop on an ambiguous project, and guides the user to a + working setup. `report` selects artifacts and their destinations through one flag family, `--output.<fmt>.path <path>` (`<fmt>` is `json`, `html`, `sarif`, `codequality`, diff --git a/docs/templates.md b/docs/templates.md index d0a809f6..0db527f5 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -141,7 +141,7 @@ substitution primitive already in the tree. Corpus publishing to GitHub Pages (and the `code-ranker docs` subcommand that composed the corpus to disk) has been **removed**. The corpus is no longer served over a URL; it lives only **embedded in the binary** (§3) and is reached through -`--doc <ID>` / inline prompt text. The Pages workflow still publishes the HTML +the `docs <ID>` command / inline prompt text. The Pages workflow still publishes the HTML *report* (`report . → site/index.html`), but not the doc corpus, so a finding's `doc_url` no longer resolves to a live page. @@ -213,13 +213,13 @@ code-ranker report . --prompt HK --top 5 --focus-path src/engine stdout counterpart — the quick "show me HK" path — and (being a standalone dump) accepts any `--top N` to widen the ranked module list. -### 7.2 `--doc <ID>` — print the raw principle doc ✅ +### 7.2 `docs <ID>` — print the raw principle doc ✅ -Dumps the embedded principle/metric Markdown itself (composed for the active -`--plugin`, with any `[templates.…]` override applied), no analysis: +The `docs` command dumps the embedded principle/metric Markdown itself (composed for the +active `--plugin`, with any `[templates.…]` override applied), no analysis and no `[input]`: ```bash -code-ranker report . --doc HK # the resolved languages/<lang>/HK.md +code-ranker docs HK # the resolved languages/<lang>/HK.md ``` ### 7.3 Existing prompt surfaces ✅ @@ -250,7 +250,7 @@ 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 report --doc <id>` command (`{id}` substituted), not a network URL | +| `doc_note` | how to read the full principle — points at the offline `code-ranker docs <id>` command (`{id}` substituted), not a network URL | | `task` | the task-protocol bullets (`{id}` → active principle id) | | `focus` | closing emphasis line | | `cycle_note` | note prepended to a single dependency-cycle's module list | @@ -285,7 +285,8 @@ and JS. | `report --output.prompt`, `check --output-format prompt` | ✅ | | Embedding the corpus in the binary (`build.rs` → `CORPUS`) | ✅ | | `[templates.languages.<lang>.<ID>]` per-file override | ✅ | -| `report --prompt <ID>` / `--doc <ID>` | ✅ | +| `report --prompt <ID>` | ✅ | +| `docs <ID>` | ✅ | | Manifest composer (`compose.rs`: `doc:base` + `from`/`to`) + `resolve_doc` wiring | ✅ | | `code-ranker docs` build subcommand + corpus Pages publishing (Variant B) | ✗ removed — corpus is binary-embedded only, not served over a URL | | `base/` + per-language manifest migration | ◐ all `rust/` docs migrated; `python`/`typescript` 🔜 | diff --git a/languages/base/AI.md b/languages/base/AI.md index dd163987..51d39f8f 100644 --- a/languages/base/AI.md +++ b/languages/base/AI.md @@ -1,6 +1,6 @@ # code-ranker — AI agent skill -**TL;DR**: `code-ranker` is a multi-language **structural analysis platform** an AI +`code-ranker` is a multi-language **structural analysis platform** an AI assistant can drive. It builds a project's dependency graph, finds the structural problems that make code hard to change — dependency **cycles** (ADP), heavy **coupling** (Henry–Kafura), and complexity hotspots — ranks them worst-first, and @@ -19,9 +19,11 @@ 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. -- **`ai`** — print this playbook. With a language plugin resolved it appends the - full principle/metric catalog; with none it explains how to select one. No - analysis; always exits `0`. +- **`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`. - **`help`** — usage for the binary or any command (`code-ranker --help`, `code-ranker <command> --help`, or `-h <command>`). Lists every flag. @@ -52,7 +54,7 @@ version = "{config_version}" plugin = "<name>" ``` -Then re-run `code-ranker ai` for the full playbook and the principle/metric catalog. +Then re-run `code-ranker docs ai` for the full playbook and the principle/metric catalog. <!-- ai:select-end --> ## The two that matter most @@ -68,25 +70,24 @@ inspect the worst tier with `--severity warning`. ## The fix loop ```sh -code-ranker check . # 1. the gate verdict -code-ranker report . --output.scorecard --focus cycle --top 1 # 2. focus one metric/principle, worst-first -code-ranker report --doc <principle> # 3. READ the deep doc — before you touch code -code-ranker report . --output.prompt.path=stdout --top 1 # 4. fix-prompt for the worst module +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 ``` -**Step 3 is not optional — read the `--doc <principle>` page before proposing a +**Step 3 is not optional — read the `docs <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 one, or drop tests. Read it first; then fix. `--focus` takes any catalog id below (a principle like `ADP`, or a metric like -`hk` / `cycle`): focusing on a metric frames the output by that metric; on a +`hk` / `loc`): focusing on a metric frames the output by that metric; on a principle, by that design principle. ## Principles & metrics -Each entry summarizes one principle or metric; run `code-ranker report --doc <ID>` +Each entry summarizes one principle or metric; run `code-ranker docs <ID>` to print its full doc (offline, straight to the terminal). <!-- doc:tldr-index --> From 32eaec93e6a9d9e6c2b140dc9a2a6f6658d5b5c8 Mon Sep 17 00:00:00 2001 From: Roman Fedorov <Roman.Fedorov@constructor.tech> Date: Thu, 25 Jun 2026 22:52:42 +0300 Subject: [PATCH 39/40] feat(docs): strictly per-plugin catalog; config-aware plugin error; clearer names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the docs reference strictly per-language, more readable, and friendlier when no plugin resolves. - Require a resolved plugin for every subject but `ai`; build_specs layers the plugin's own level node-attribute specs (new plugin::levels / config::attribute_groups, no analysis) over the central catalog, so a language metric like Rust's `unsafe` is a real `docs` subject. Subject matching is separator/case-insensitive. - Clearer metric names in the base TOMLs + de-duplicated, informative category headers (`<key> — <description>`); new `rust` category; `loc`/`cycle` categorized (no "(uncategorized)" bucket). - Plugin-resolution failure suggests pinning the language in config: add `plugin = "<name>"` to the discovered code-ranker.toml, or create one when none exists (resolve_plugin gained a config-path arg). - main prints the error once (stamped `error:` line + non-zero exit) instead of also returning Err for the runtime to re-print. - check's metric `fix` line is auto-derived from the key when a metric carries no authored remediation. Tests: render-index / levels / config-hint / auto-fix unit tests added; rules.rs inline tests moved to a sibling rules_test.rs (the project's own TST gate). Docs synced (CLI/PRD/DESIGN). Goldens regenerated + frozen. No format-version bump (docs is new this branch; snapshot shape unchanged). make all green (cov 96.24%). --- crates/code-ranker-cli/src/config/rules.rs | 101 +----------- .../code-ranker-cli/src/config/rules_test.rs | 115 ++++++++++++++ crates/code-ranker-cli/src/docs.rs | 149 +++++++++++------- crates/code-ranker-cli/src/docs_test.rs | 35 +++- crates/code-ranker-cli/src/export.rs | 1 + crates/code-ranker-cli/src/main.rs | 16 +- crates/code-ranker-cli/src/pipeline.rs | 8 +- crates/code-ranker-cli/src/plugin/mod.rs | 35 +++- crates/code-ranker-cli/src/plugin/mod_test.rs | 37 ++++- crates/code-ranker-cli/tests/e2e.rs | 69 +++++++- crates/code-ranker-graph/metrics/builtin.toml | 32 ++-- crates/code-ranker-graph/src/builtin_test.rs | 5 +- crates/code-ranker-plugins/src/config/mod.rs | 5 +- .../code-ranker-plugins/src/config/views.rs | 13 +- crates/code-ranker-plugins/src/defaults.toml | 2 + .../c/tests/sample/code-ranker-report.json | 36 +++-- .../cpp/tests/sample/code-ranker-report.json | 38 ++--- .../tests/sample/code-ranker-report.json | 38 ++--- .../go/tests/sample/code-ranker-report.json | 38 ++--- .../tests/sample/code-ranker-report.json | 41 ++--- .../tests/sample/code-ranker-report.json | 17 +- .../tests/sample/code-ranker-report.json | 41 ++--- .../src/languages/rust/config.toml | 16 ++ .../src/languages/rust/mod.rs | 2 +- .../rust/tests/sample/code-ranker-report.json | 53 ++++--- .../tests/sample/code-ranker-report.json | 41 ++--- docs/code-ranker-cli/CLI.md | 20 ++- docs/code-ranker-cli/DESIGN.md | 15 +- docs/code-ranker-cli/PRD.md | 32 ++-- 29 files changed, 672 insertions(+), 379 deletions(-) create mode 100644 crates/code-ranker-cli/src/config/rules_test.rs diff --git a/crates/code-ranker-cli/src/config/rules.rs b/crates/code-ranker-cli/src/config/rules.rs index 435f87de..01aa635c 100644 --- a/crates/code-ranker-cli/src/config/rules.rs +++ b/crates/code-ranker-cli/src/config/rules.rs @@ -102,102 +102,5 @@ pub fn rule_tuning(id: &str) -> String { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn rule_doc_resolves_why_fix_from_specs_and_cycle_kinds() { - use code_ranker_plugin_api::attrs::ValueType; - let mut na = BTreeMap::new(); - let mut hk = AttributeSpec::new(ValueType::Float, "HK"); - hk.name = Some("Henry–Kafura".into()); - hk.description = Some("why-hk".into()); - hk.remediation = Some("fix-hk".into()); - na.insert("hk".to_string(), hk); - let mut ck = BTreeMap::new(); - ck.insert( - "mutual".to_string(), - CycleKindSpec { - label: Some("Mutual".into()), - description: Some("why-cyc".into()), - remediation: Some("fix-cyc".into()), - }, - ); - - // A threshold id resolves to its metric's node-attribute spec. - let m = rule_doc("threshold.file.hk", &na, &ck).expect("metric doc"); - assert_eq!(m.title.as_deref(), Some("Henry–Kafura")); - assert_eq!(m.why.as_deref(), Some("why-hk")); - assert_eq!(m.fix.as_deref(), Some("fix-hk")); - // A cycle id resolves to the cycle-kind spec. - let c = rule_doc("cycle.mutual", &na, &ck).expect("cycle doc"); - assert_eq!(c.why.as_deref(), Some("why-cyc")); - assert_eq!(c.fix.as_deref(), Some("fix-cyc")); - // An unknown metric has no spec → no doc. - assert!(rule_doc("threshold.file.bogus", &na, &ck).is_none()); - } - - #[test] - fn rule_group_resolves_threshold_and_cycle_ids() { - assert_eq!(rule_group("threshold.file.sloc"), "SIZ"); - assert_eq!(rule_group("threshold.file.cyclomatic"), "CPX"); - assert_eq!(rule_group("threshold.file.hk"), "CPL"); - assert_eq!(rule_group("cycle.mutual"), "CYC"); - assert_eq!(rule_group("threshold.file.bogus"), "?"); - } - - #[test] - fn apply_cycle_rules_strips_disabled_kind() { - use crate::config::model::CycleRule; - let mut cycles = vec![CycleGroup { - kind: "mutual".into(), - nodes: vec!["a".into(), "b".into()], - }]; - let mut nodes: Vec<Node> = vec![]; - // A kind whose budget is disabled is stripped from the groups. - let rules = CycleRules { - mutual: CycleRule::Off, - chain: CycleRule::Max(0), - }; - apply_cycle_rules(&mut cycles, &mut nodes, &rules); - assert!(cycles.is_empty(), "disabled kind -> stripped"); - } - - #[test] - fn apply_cycle_rules_clears_disabled_cycle_attr_on_nodes() { - use crate::config::model::CycleRule; - let mut cycles: Vec<CycleGroup> = vec![]; - let node = |id: &str, kind: &str| Node { - id: id.into(), - kind: "file".into(), - name: id.into(), - parent: None, - attrs: [("cycle".to_string(), AttrValue::Str(kind.into()))] - .into_iter() - .collect(), - }; - // `mutual` is disabled, `chain` keeps a budget — only the mutual node's - // `cycle` attribute is cleared. - let mut nodes = vec![node("a", "mutual"), node("b", "chain")]; - let rules = CycleRules { - mutual: CycleRule::Off, - chain: CycleRule::Max(3), - }; - apply_cycle_rules(&mut cycles, &mut nodes, &rules); - assert!( - !nodes[0].attrs.contains_key("cycle"), - "disabled-kind cycle attr cleared" - ); - assert!( - nodes[1].attrs.contains_key("cycle"), - "an enabled kind's attr is kept" - ); - } - - #[test] - fn rule_tuning_emits_cli_and_config_hints() { - assert!(rule_tuning("cycle.mutual").contains("--cycle-rule mutual=off")); - assert!(rule_tuning("threshold.file.hk").contains("--threshold file.hk=N")); - assert_eq!(rule_tuning("bogus.id"), ""); - } -} +#[path = "rules_test.rs"] +mod tests; diff --git a/crates/code-ranker-cli/src/config/rules_test.rs b/crates/code-ranker-cli/src/config/rules_test.rs new file mode 100644 index 00000000..bdde63cc --- /dev/null +++ b/crates/code-ranker-cli/src/config/rules_test.rs @@ -0,0 +1,115 @@ +use super::*; + +#[test] +fn rule_doc_resolves_why_fix_from_specs_and_cycle_kinds() { + use code_ranker_plugin_api::attrs::ValueType; + let mut na = BTreeMap::new(); + let mut hk = AttributeSpec::new(ValueType::Float, "HK"); + hk.name = Some("Henry–Kafura".into()); + hk.description = Some("why-hk".into()); + hk.remediation = Some("fix-hk".into()); + na.insert("hk".to_string(), hk); + let mut ck = BTreeMap::new(); + ck.insert( + "mutual".to_string(), + CycleKindSpec { + label: Some("Mutual".into()), + description: Some("why-cyc".into()), + remediation: Some("fix-cyc".into()), + }, + ); + + // A threshold id resolves to its metric's node-attribute spec. + let m = rule_doc("threshold.file.hk", &na, &ck).expect("metric doc"); + assert_eq!(m.title.as_deref(), Some("Henry–Kafura")); + assert_eq!(m.why.as_deref(), Some("why-hk")); + assert_eq!(m.fix.as_deref(), Some("fix-hk")); + // A cycle id resolves to the cycle-kind spec. + let c = rule_doc("cycle.mutual", &na, &ck).expect("cycle doc"); + assert_eq!(c.why.as_deref(), Some("why-cyc")); + assert_eq!(c.fix.as_deref(), Some("fix-cyc")); + // An unknown metric has no spec → no doc. + assert!(rule_doc("threshold.file.bogus", &na, &ck).is_none()); +} + +#[test] +fn rule_doc_auto_derives_fix_for_a_metric_without_remediation() { + use code_ranker_plugin_api::attrs::ValueType; + let mut na = BTreeMap::new(); + // A built-in metric carries no boilerplate `remediation`; the `fix` line is + // derived from the key as a pointer to its `docs` page. + na.insert( + "sloc".to_string(), + AttributeSpec::new(ValueType::Int, "Source"), + ); + let ck = BTreeMap::new(); + let m = rule_doc("threshold.file.sloc", &na, &ck).expect("metric doc"); + assert_eq!( + m.fix.as_deref(), + Some("Run `code-ranker docs sloc` and follow its instructions.") + ); +} + +#[test] +fn rule_group_resolves_threshold_and_cycle_ids() { + assert_eq!(rule_group("threshold.file.sloc"), "SIZ"); + assert_eq!(rule_group("threshold.file.cyclomatic"), "CPX"); + assert_eq!(rule_group("threshold.file.hk"), "CPL"); + assert_eq!(rule_group("cycle.mutual"), "CYC"); + assert_eq!(rule_group("threshold.file.bogus"), "?"); +} + +#[test] +fn apply_cycle_rules_strips_disabled_kind() { + use crate::config::model::CycleRule; + let mut cycles = vec![CycleGroup { + kind: "mutual".into(), + nodes: vec!["a".into(), "b".into()], + }]; + let mut nodes: Vec<Node> = vec![]; + // A kind whose budget is disabled is stripped from the groups. + let rules = CycleRules { + mutual: CycleRule::Off, + chain: CycleRule::Max(0), + }; + apply_cycle_rules(&mut cycles, &mut nodes, &rules); + assert!(cycles.is_empty(), "disabled kind -> stripped"); +} + +#[test] +fn apply_cycle_rules_clears_disabled_cycle_attr_on_nodes() { + use crate::config::model::CycleRule; + let mut cycles: Vec<CycleGroup> = vec![]; + let node = |id: &str, kind: &str| Node { + id: id.into(), + kind: "file".into(), + name: id.into(), + parent: None, + attrs: [("cycle".to_string(), AttrValue::Str(kind.into()))] + .into_iter() + .collect(), + }; + // `mutual` is disabled, `chain` keeps a budget — only the mutual node's + // `cycle` attribute is cleared. + let mut nodes = vec![node("a", "mutual"), node("b", "chain")]; + let rules = CycleRules { + mutual: CycleRule::Off, + chain: CycleRule::Max(3), + }; + apply_cycle_rules(&mut cycles, &mut nodes, &rules); + assert!( + !nodes[0].attrs.contains_key("cycle"), + "disabled-kind cycle attr cleared" + ); + assert!( + nodes[1].attrs.contains_key("cycle"), + "an enabled kind's attr is kept" + ); +} + +#[test] +fn rule_tuning_emits_cli_and_config_hints() { + assert!(rule_tuning("cycle.mutual").contains("--cycle-rule mutual=off")); + assert!(rule_tuning("threshold.file.hk").contains("--threshold file.hk=N")); + assert_eq!(rule_tuning("bogus.id"), ""); +} diff --git a/crates/code-ranker-cli/src/docs.rs b/crates/code-ranker-cli/src/docs.rs index 159ed404..efaa4927 100644 --- a/crates/code-ranker-cli/src/docs.rs +++ b/crates/code-ranker-cli/src/docs.rs @@ -1,20 +1,24 @@ -//! The `docs <subject>` command: print a reference doc to stdout. No analysis — -//! it resolves the merged config (auto-discovered from the current directory) and -//! the language plugin best-effort, then builds the principle + metric + category -//! specs from config + plugin (the same specs an analyzed snapshot carries, minus -//! the graph). Subjects: -//! - `ai` → the offline AI-agent playbook (resolved plugin → full -//! playbook + catalog; none → a brief intro + plugin-setup guidance); -//! - `metrics` → an index of every metric, grouped by category; -//! - `principles` → an index of every design principle; -//! - `<category>` → that category (`loc`, `complexity`, …) + its member metrics; -//! - `<metric>` → that metric's spec card, plus its full doc when one exists; -//! - `<principle>` → its full doc (or a synthetic card for a doc-less custom one); -//! - anything else (or no subject) → a friendly catalog of every subject. +//! The `docs <subject>` command: print a reference doc to stdout. No analysis — it +//! resolves the merged config (auto-discovered from the current directory) and the +//! language plugin, then builds the principle + metric + category specs from the +//! config and plugin (the same specs an analyzed snapshot carries, minus the graph). +//! A reference doc is **strictly per-language**: every subject but `ai` requires a +//! resolved plugin and fails (same diagnostic as `check` / `report`) when none does. +//! Subjects match separator/case-insensitively (`fan_in` = `Fan-in` = `FAN in`): //! -//! Categories and metrics are read from the spec dictionaries; principle ids and -//! custom metrics declared in the project config (`[principles.<ID>]` / -//! `[metrics.<key>]`) are first-class subjects too. +//! - `ai` → the offline AI-agent playbook (resolved plugin → full playbook + catalog; +//! none → a brief intro + how to pick a plugin — the one subject that does not +//! hard-fail without a plugin); +//! - `metrics` / `principles` → an index of every metric / design principle; +//! - `<category>` → that category (`loc`, `complexity`, …) + its member metrics; +//! - `<metric>` → its spec card (incl. language metrics like Rust's `unsafe`), plus +//! its prose doc when one exists; +//! - `<principle>` → its full doc (or a synthetic card for a doc-less custom one); +//! - anything else (or no subject) → a catalog of every subject. +//! +//! Categories and metrics are read from the plugin's level specs + the central +//! catalog; principle ids and custom metrics declared in the project config +//! (`[principles.<ID>]` / `[metrics.<key>]`) are first-class subjects too. use anyhow::{Result, bail}; use code_ranker_graph::version::CONFIG_VERSION; @@ -44,12 +48,29 @@ pub(crate) fn run( plugin_arg: Option<&str>, config_entries: &[String], ) -> Result<()> { - // `ai` is special: it needs no specs and has its own resolved/unresolved modes. + // `docs ai` is special: the playbook stands on its own and, with no plugin + // resolved, prints the intro that explains how to pick one (no hard error). if subject.is_some_and(|s| templates::normalize_id(s) == "ai") { return run_ai(plugin_arg, config_entries); } - let specs = build_specs(plugin_arg, config_entries); + // Every other subject is strictly per-language — a reference doc describes one + // plugin's principles + metrics — so a plugin MUST resolve. When none does, fail + // with the same diagnostic `check` / `report` give (ambiguous / no marker → name + // one with `--plugin`, or set `plugin` in `code-ranker.toml`). + let input = std::path::Path::new("."); + let loaded = config::load(input, config_entries, &[], &[], &[]).ok(); + let config_file = loaded.as_ref().and_then(|l| l.source_file.clone()); + let cfg = loaded.map(|loaded| loaded.config); + let cfg_plugin = cfg.as_ref().and_then(|c| c.plugin.clone()); + let plugin_name = plugin::resolve_plugin( + plugin_arg, + cfg_plugin.as_deref(), + input, + config_file.as_deref(), + )?; + + let specs = build_specs(&plugin_name, cfg); let Some(subject) = subject else { // Bare `docs`: the catalog is the help, so exit 0. @@ -102,9 +123,15 @@ fn run_ai(plugin_arg: Option<&str>, config_entries: &[String]) -> Result<()> { let cfg_plugin = config::load(input, config_entries, &[], &[], &[]) .ok() .and_then(|loaded| loaded.config.plugin); - let md = match plugin::resolve_plugin(plugin_arg, cfg_plugin.as_deref(), input) { + // `docs ai` carries its own *Select a language* template, so the intro only + // needs the bare "why" — pass no config hint and keep just its first line. + let md = match plugin::resolve_plugin(plugin_arg, cfg_plugin.as_deref(), input, None) { Ok(_) => templates::ai_doc()?, - Err(reason) => fill_select(&templates::ai_doc_intro()?, &reason.to_string()), + Err(reason) => { + let reason = reason.to_string(); + let why = reason.lines().next().unwrap_or(&reason); + fill_select(&templates::ai_doc_intro()?, why) + } }; emit(md); Ok(()) @@ -119,26 +146,35 @@ fn fill_select(intro: &str, reason: &str) -> String { .replace("{config_version}", CONFIG_VERSION) } -/// Build the doc specs from config + plugin, no analysis. Best-effort throughout: -/// a missing config yields the built-in defaults, a *broken* one is ignored (the -/// central catalog + plugin still answer most subjects), and an unresolved plugin -/// just drops the language-specific principles/refinements. -fn build_specs(plugin_arg: Option<&str>, config_entries: &[String]) -> DocSpecs { - let input = Path::new("."); - - // Central, language-neutral metric specs + their category groups. +/// Build the doc specs strictly for one resolved `plugin_name`, no analysis. The +/// node-attribute dictionary is the plugin's own `files`-level specs (its +/// `[node_attributes.*]` — e.g. Rust's `unsafe` / `items`) layered with the central +/// complexity + coupling specs and the project's node-scope `[metrics.<key>]`; +/// principles are the plugin catalog overlaid with `[principles.<ID>]`. Config is +/// best-effort (a broken file degrades to the plugin's own specs). +fn build_specs(plugin_name: &str, cfg: Option<config::model::Config>) -> DocSpecs { + // Central, language-neutral metric specs + their category groups, refined by + // the active plugin (e.g. Rust's `#[cfg(test)]` LOC nuance). let (default_metric_specs, metric_groups) = code_ranker_graph::metric_specs(); let (coupling_specs, coupling_groups) = code_ranker_graph::coupling_specs(); - let mut groups = BTreeMap::new(); - groups.extend(metric_groups); - groups.extend(coupling_groups); + let metric_specs = plugin::metric_specs(plugin_name, default_metric_specs); - let cfg = config::load(input, config_entries, &[], &[], &[]) - .ok() - .map(|loaded| loaded.config); + // The plugin's own structural attribute specs + category groups, taken from the + // `files` level WITHOUT analysis — this is what surfaces language metrics like + // Rust's `unsafe` that live in `[node_attributes.*]`, not the central catalog. + let files_level = plugin::levels(plugin_name) + .into_iter() + .find(|l| l.name == "files"); + let mut node_attributes = files_level + .as_ref() + .map(|l| l.node_attributes.clone()) + .unwrap_or_default(); + node_attributes.extend(metric_specs); + node_attributes.extend(coupling_specs); - let cfg_plugin = cfg.as_ref().and_then(|c| c.plugin.clone()); - let plugin_name = plugin::resolve_plugin(plugin_arg, cfg_plugin.as_deref(), input).ok(); + let mut groups = files_level.map(|l| l.attribute_groups).unwrap_or_default(); + groups.extend(metric_groups); + groups.extend(coupling_groups); let pinput = cfg .as_ref() @@ -150,15 +186,7 @@ fn build_specs(plugin_arg: Option<&str>, config_entries: &[String]) -> DocSpecs hidden: c.ignore.hidden, }); - // Metrics: central catalog refined by the active plugin, plus coupling and the - // project's node-scope declarative metrics (built-ins win a key collision). - let metric_specs = match &plugin_name { - Some(n) => plugin::metric_specs(n, default_metric_specs), - None => default_metric_specs, - }; - let mut node_attributes: BTreeMap<String, AttributeSpec> = BTreeMap::new(); - node_attributes.extend(metric_specs); - node_attributes.extend(coupling_specs); + // Project node-scope declarative metrics (built-ins win a key collision). if let Some(c) = &cfg { for (k, d) in &c.metrics { if d.scope == code_ranker_graph::Scope::Node { @@ -170,10 +198,7 @@ fn build_specs(plugin_arg: Option<&str>, config_entries: &[String]) -> DocSpecs } // Principles: plugin catalog overlaid with the project's `[principles.<ID>]`. - let catalog = match &plugin_name { - Some(n) => plugin::principles(n, &pinput), - None => Vec::new(), - }; + let catalog = plugin::principles(plugin_name, &pinput); let principles = match &cfg { Some(c) => config::merge_project_principles(catalog, &c.principles), None => catalog, @@ -271,20 +296,26 @@ fn categories_block(specs: &DocSpecs) -> String { let mut out = String::new(); let cats = category_keys(specs); for key in &cats { - out.push_str(&format!("\n {key}: {}", category_label(specs, key))); - if let Some(d) = specs.groups.get(key).and_then(|g| g.description.as_deref()) { - out.push_str(&format!(" — {d}")); + // Header is `<key> — <description>`: the key is what you type (`docs <key>`), + // the description says what the category measures. The Titlecase `label` is + // dropped here — it just echoes the key (`complexity` ≈ "Complexity"). + out.push_str(&format!("\n {key}")); + match specs.groups.get(key).and_then(|g| g.description.as_deref()) { + Some(d) => out.push_str(&format!(" — {d}")), + None => out.push_str(&format!(" — {}", category_label(specs, key))), } out.push('\n'); for (k, spec) in metrics_in_category(specs, key) { out.push_str(&format!(" - {k}: {}\n", metric_name(spec, k))); } } - // Metrics with no category (e.g. the categorical `cycle`): list them too. + // Metrics with no category (e.g. the categorical `cycle`, Rust's `unsafe`): list + // them too — but only those with a description (skips bare external-node metadata + // like `crate` / `version` that carry no doc copy). let uncategorized: Vec<_> = specs .node_attributes .iter() - .filter(|(_, s)| s.group.is_none()) + .filter(|(_, s)| s.group.is_none() && s.description.is_some()) .collect(); if !uncategorized.is_empty() { out.push_str("\n (uncategorized)\n"); @@ -298,7 +329,7 @@ fn categories_block(specs: &DocSpecs) -> String { /// The principles section shared by `docs principles` and the catalog. fn principles_block(specs: &DocSpecs) -> String { if specs.principles.is_empty() { - return " (none — no language plugin resolved here)\n".to_string(); + return " (none — this plugin defines no principles)\n".to_string(); } specs .principles @@ -323,9 +354,11 @@ fn render_principles_index(specs: &DocSpecs) -> String { ) } -/// `docs <category>`: the category label + description + its member metrics. +/// `docs <category>`: the category's human label + description + its member metrics. fn render_category(specs: &DocSpecs, key: &str) -> String { - let mut out = format!("{key}: {}", category_label(specs, key)); + // 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}")); } @@ -416,7 +449,7 @@ fn render_catalog(specs: &DocSpecs, unknown: Option<&str>) -> String { out.push_str("code-ranker docs <subject> — print a reference doc to stdout (no analysis).\n"); out.push_str(&categories_block(specs)); // Principles render as one more group, exactly like a metric category. - out.push_str("\n principles: Design principles\n"); + out.push_str("\n principles — SOLID & related design principles\n"); out.push_str( &specs .principles diff --git a/crates/code-ranker-cli/src/docs_test.rs b/crates/code-ranker-cli/src/docs_test.rs index 8d46019f..e78d92de 100644 --- a/crates/code-ranker-cli/src/docs_test.rs +++ b/crates/code-ranker-cli/src/docs_test.rs @@ -86,7 +86,7 @@ fn category_subject_resolves_case_insensitively() { #[test] fn render_category_lists_label_description_and_members() { let out = render_category(&specs(), "loc"); - assert!(out.contains("loc: Lines of Code"), "header: {out}"); + assert!(out.contains("Lines of Code"), "header (human label): {out}"); assert!( out.contains("Lines of code breakdown"), "description: {out}" @@ -127,15 +127,18 @@ fn catalog_lists_every_subject_class() { out.contains("Unknown docs subject `zzz`"), "lead note: {out}" ); - // Categories and their metrics (two-level). - assert!(out.contains("loc: Lines of Code"), "category group: {out}"); + // Categories and their metrics (two-level): `<key> — <description>` header. + assert!( + out.contains("loc — Lines of code breakdown"), + "category group: {out}" + ); assert!( out.contains("- sloc: Source lines"), "category member: {out}" ); // Principles render as one more group. assert!( - out.contains("principles: Design principles"), + out.contains("principles — SOLID"), "principles group: {out}" ); assert!(out.contains("- TSR: Test Ratio"), "principle member: {out}"); @@ -145,3 +148,27 @@ fn catalog_lists_every_subject_class() { "closing note: {out}" ); } + +#[test] +fn metrics_index_lists_categories_and_members() { + let out = render_metrics_index(&specs()); + assert!( + out.contains("loc — Lines of code breakdown"), + "category: {out}" + ); + assert!(out.contains("- sloc: Source lines"), "member: {out}"); +} + +#[test] +fn principles_index_lists_each_principle() { + let out = render_principles_index(&specs()); + assert!(out.contains("- TSR: Test Ratio"), "principle listed: {out}"); +} + +#[test] +fn principles_block_reports_when_the_plugin_defines_none() { + let mut s = specs(); + s.principles.clear(); + let out = render_principles_index(&s); + assert!(out.contains("(none"), "empty principles note: {out}"); +} diff --git a/crates/code-ranker-cli/src/export.rs b/crates/code-ranker-cli/src/export.rs index d5eb71c0..271594db 100644 --- a/crates/code-ranker-cli/src/export.rs +++ b/crates/code-ranker-cli/src/export.rs @@ -38,6 +38,7 @@ pub(crate) fn export_full_config(args: &AnalyzeArgs, out: &Path) -> Result<()> { args.plugin.as_deref(), loaded.config.plugin.as_deref(), &workspace, + loaded.source_file.as_deref(), )?; let plugin_table = plugin::registry() .into_iter() diff --git a/crates/code-ranker-cli/src/main.rs b/crates/code-ranker-cli/src/main.rs index 3fbc6e4c..d1b4207c 100644 --- a/crates/code-ranker-cli/src/main.rs +++ b/crates/code-ranker-cli/src/main.rs @@ -22,13 +22,12 @@ mod recommend; mod report; mod templates; -use anyhow::Result; use clap::Parser; use code_ranker_plugin_api::log; use cli::{Cli, Command, OutputMode}; -fn main() -> Result<()> { +fn main() { let cli = Cli::parse(); // Apply the verbosity before emitting anything: every later line (here, in the // stages, and in the plugins) reads this one switch. `--output.mode` is global, @@ -140,13 +139,18 @@ fn main() -> Result<()> { config, } => docs::run(subject.as_deref(), plugin.as_deref(), &config), }; - match &res { - Ok(_) => { + match res { + Ok(()) => { if let Some(t) = timer { t.finish(); } } - Err(e) => logger::error(&format!("error: {e:#}")), + // Print the error ourselves (one stamped `error:` line) and exit non-zero. + // `main` returns `()` rather than `Result` so the runtime does NOT also print + // its own `Error: …` line — that double-print is exactly what we avoid here. + Err(e) => { + logger::error(&format!("error: {e:#}")); + std::process::exit(1); + } } - res } diff --git a/crates/code-ranker-cli/src/pipeline.rs b/crates/code-ranker-cli/src/pipeline.rs index 3ec0525f..f7574eb6 100644 --- a/crates/code-ranker-cli/src/pipeline.rs +++ b/crates/code-ranker-cli/src/pipeline.rs @@ -55,8 +55,12 @@ pub(crate) fn analyze_directory( .context("configuration error")?; let cfg = loaded.config; - let plugin_name = - plugin::resolve_plugin(args.plugin.as_deref(), cfg.plugin.as_deref(), &target)?; + let plugin_name = plugin::resolve_plugin( + args.plugin.as_deref(), + cfg.plugin.as_deref(), + &target, + loaded.source_file.as_deref(), + )?; let command = format!( "code-ranker {}", diff --git a/crates/code-ranker-cli/src/plugin/mod.rs b/crates/code-ranker-cli/src/plugin/mod.rs index 552b0ecc..06c06203 100644 --- a/crates/code-ranker-cli/src/plugin/mod.rs +++ b/crates/code-ranker-cli/src/plugin/mod.rs @@ -5,6 +5,7 @@ //! `name()`. Adding a language is a self-contained module in the plugins crate. use anyhow::{Result, bail}; +use code_ranker_graph::version::CONFIG_VERSION; use code_ranker_graph::write_metrics; use code_ranker_plugin_api::{ graph::Graph, @@ -125,6 +126,17 @@ pub fn principles(name: &str, input: &PluginInput) -> Vec<Principle> { } } +/// The matching plugin's level specs — its node-attribute / edge-kind / group +/// dictionaries, built from config with **no analysis**. The `docs` command reads +/// the `files` level to surface a language's own structural metrics (e.g. Rust's +/// `unsafe`, `items`) without walking a source tree. +pub fn levels(name: &str) -> Vec<code_ranker_plugin_api::level::Level> { + match registry().iter().find(|p| p.name() == name) { + Some(p) => p.levels(), + None => Vec::new(), + } +} + /// Let the matching plugin refine the language-neutral default metric specs /// (e.g. add Rust-specific `#[cfg(test)]` nuance to LOC descriptions). The /// neutral catalog comes from `code-ranker-graph`; the plugin overrides only @@ -166,7 +178,12 @@ pub fn detect(workspace: &Path, input: &PluginInput) -> Result<String> { /// Resolve the plugin name: explicit `--plugin` > config `plugin` > auto-detect. /// A value of `auto` (or absence) triggers project-marker detection. Lives here, /// with the registry and [`detect`], so plugin selection is one concern. -pub fn resolve_plugin(arg: Option<&str>, cfg: Option<&str>, workspace: &Path) -> Result<String> { +pub fn resolve_plugin( + arg: Option<&str>, + cfg: Option<&str>, + workspace: &Path, + config_file: Option<&str>, +) -> Result<String> { if let Some(p) = arg && p != "auto" { @@ -177,7 +194,21 @@ pub fn resolve_plugin(arg: Option<&str>, cfg: Option<&str>, workspace: &Path) -> { return Ok(p.to_string()); } - detect(workspace, &PluginInput::default()) + // Auto-detect failed (no marker / ambiguous): append a config-aware way to pin + // the language, so the user isn't left with only `--plugin` on every run. + detect(workspace, &PluginInput::default()).map_err(|e| with_config_hint(e, config_file)) +} + +/// Augment a failed-detection error with how to pin the language in config: add +/// `plugin` to the discovered `code-ranker.toml`, or create one when none exists. +fn with_config_hint(e: anyhow::Error, config_file: Option<&str>) -> anyhow::Error { + let how = match config_file { + Some(path) => format!("add `plugin = \"<name>\"` to {path}"), + None => format!( + "create a `code-ranker.toml` at the project root with:\n version = \"{CONFIG_VERSION}\"\n plugin = \"<name>\"" + ), + }; + anyhow::anyhow!("{e}\n → or pin the language in config: {how}") } #[cfg(test)] diff --git a/crates/code-ranker-cli/src/plugin/mod_test.rs b/crates/code-ranker-cli/src/plugin/mod_test.rs index 5b6dd6ac..19ca09b0 100644 --- a/crates/code-ranker-cli/src/plugin/mod_test.rs +++ b/crates/code-ranker-cli/src/plugin/mod_test.rs @@ -88,23 +88,52 @@ fn resolve_plugin_precedence_explicit_then_config_then_auto() { let d = tempfile::tempdir().unwrap(); std::fs::write(d.path().join("pyproject.toml"), "").unwrap(); assert_eq!( - resolve_plugin(Some("rust"), Some("javascript"), d.path()).unwrap(), + resolve_plugin(Some("rust"), Some("javascript"), d.path(), None).unwrap(), "rust", "explicit --plugin wins" ); assert_eq!( - resolve_plugin(None, Some("rust"), d.path()).unwrap(), + resolve_plugin(None, Some("rust"), d.path(), None).unwrap(), "rust", "config wins over auto-detect" ); assert_eq!( - resolve_plugin(Some("auto"), None, d.path()).unwrap(), + resolve_plugin(Some("auto"), None, d.path(), None).unwrap(), "python", "explicit auto -> detect" ); assert_eq!( - resolve_plugin(None, None, d.path()).unwrap(), + resolve_plugin(None, None, d.path(), None).unwrap(), "python", "no plugin -> detect" ); } + +#[test] +fn levels_returns_the_spec_for_a_known_plugin_and_empty_for_unknown() { + // A real plugin publishes its `files` level (no analysis); an unknown name is + // an empty list, not a panic. + assert!( + levels("rust").iter().any(|l| l.name == "files"), + "rust publishes a files level" + ); + assert!(levels("nope").is_empty(), "unknown plugin → no levels"); +} + +#[test] +fn resolve_plugin_failure_points_at_config() { + // No marker resolves here, so the error guides the user to pin the language — + // into the discovered config when one exists, else by creating `code-ranker.toml`. + let d = tempfile::tempdir().unwrap(); + let with_cfg = + resolve_plugin(None, None, d.path(), Some("/proj/code-ranker.toml")).unwrap_err(); + assert!( + format!("{with_cfg:#}").contains("add `plugin = \"<name>\"` to /proj/code-ranker.toml"), + "suggests editing the existing config: {with_cfg:#}" + ); + let no_cfg = resolve_plugin(None, None, d.path(), None).unwrap_err(); + assert!( + format!("{no_cfg:#}").contains("create a `code-ranker.toml`"), + "suggests creating a config: {no_cfg:#}" + ); +} diff --git a/crates/code-ranker-cli/tests/e2e.rs b/crates/code-ranker-cli/tests/e2e.rs index c7170c5f..fd0cf1ea 100644 --- a/crates/code-ranker-cli/tests/e2e.rs +++ b/crates/code-ranker-cli/tests/e2e.rs @@ -804,7 +804,7 @@ fn rust_sample_output_prompt_focus_metric_uses_metric_lens() { ); assert!(ok, "focus-metric prompt failed: {stderr}"); assert!( - stdout.starts_with("# HK — Henry–Kafura"), + stdout.starts_with("# HK — God-object risk"), "metric-lens prompt titled by the metric: {stdout}" ); } @@ -831,8 +831,8 @@ fn rust_sample_output_prompt_focus_principle_targets_it() { } /// `docs <ID>` prints a reference doc with no analysis. A metric (`hk`) renders its -/// spec card and then appends the embedded corpus doc (reached via the metric's -/// remediation reference); a principle (`SRP`) prints its full corpus doc. +/// spec card and then appends the embedded corpus doc (resolved from the key); a +/// principle (`SRP`) prints its full corpus doc. #[test] fn rust_sample_docs_subject_prints_embedded_markdown() { let run = |subject: &str| -> (bool, String) { @@ -849,7 +849,7 @@ fn rust_sample_docs_subject_prints_embedded_markdown() { let (ok, stdout) = run("hk"); assert!(ok, "docs hk failed"); assert!( - stdout.starts_with("# hk: Henry–Kafura"), + stdout.starts_with("# hk: God-object risk"), "metric spec card first: {stdout}" ); assert!( @@ -866,28 +866,83 @@ fn rust_sample_docs_subject_prints_embedded_markdown() { // A metric category prints its label + member metrics. let (ok3, stdout3) = run("loc"); assert!( - ok3 && stdout3.contains("loc: Lines of Code") && stdout3.contains("- sloc:"), + ok3 && stdout3.contains("Lines of Code") && stdout3.contains("- sloc:"), "category listing: {stdout3}" ); // Subjects match separator/case-insensitively: `FAN out` resolves `fan_out`. let (ok_norm, stdout_norm) = run("FAN out"); assert!( - ok_norm && stdout_norm.starts_with("# fan_out: Fan-out"), + ok_norm && stdout_norm.starts_with("# fan_out: Outgoing dependencies"), "normalized subject resolves the metric: {stdout_norm}" ); + // A language-specific metric (Rust's `unsafe`, from `[node_attributes.*]`) is + // surfaced too — both as its own subject and in the metrics index. + let (ok_u, stdout_u) = run("unsafe"); + assert!( + ok_u && stdout_u.starts_with("# unsafe: Unsafe"), + "rust `unsafe` metric card: {stdout_u}" + ); + let (ok_m, stdout_m) = run("metrics"); + assert!( + ok_m && stdout_m.contains("- unsafe: Unsafe"), + "metrics index lists the rust `unsafe` metric: {stdout_m}" + ); + // An unknown subject prints the catalog and exits non-zero. let (ok4, stdout4) = run("nope"); assert!(!ok4, "unknown subject must exit non-zero"); assert!( stdout4.contains("Unknown docs subject `nope`") - && stdout4.contains("principles: Design principles") + && stdout4.contains("principles — SOLID") && stdout4.contains("Call `docs`"), "catalog shown for an unknown subject: {stdout4}" ); } +/// `docs` is strictly per-language: with no plugin resolvable (an empty directory — +/// no markers), every subject but `ai` fails with the same diagnostic `check` / +/// `report` give, pointing the user at `--plugin`. +#[test] +fn docs_requires_a_resolved_plugin() { + let dir = std::env::temp_dir().join("cr-e2e-docs-no-plugin"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let res = Command::new(env!("CARGO_BIN_EXE_code-ranker")) + .current_dir(&dir) + .args(["docs", "metrics"]) + .output() + .expect("spawn docs metrics"); + assert!( + !res.status.success(), + "docs with no resolvable plugin must exit non-zero" + ); + let stderr = String::from_utf8_lossy(&res.stderr); + assert!( + stderr.contains("--plugin"), + "error points the user at --plugin: {stderr}" + ); + // With no config present, the error also says to create a `code-ranker.toml`. + assert!( + stderr.contains("code-ranker.toml") && stderr.contains("plugin ="), + "error suggests pinning the plugin in config: {stderr}" + ); + // The error is printed once (our stamped `error:` line) — the runtime does not + // also emit its own `Error:` line. + assert!( + !stderr.contains("Error:"), + "error is not double-printed: {stderr}" + ); + // `docs ai` still works there — it prints the intro on how to pick a plugin. + let ai = Command::new(env!("CARGO_BIN_EXE_code-ranker")) + .current_dir(&dir) + .args(["docs", "ai"]) + .output() + .expect("spawn docs ai"); + assert!(ai.status.success(), "docs ai succeeds with no plugin"); +} + /// `--focus <metric>` frames the scorecard by that metric. `--focus cycle` /// shows the dependency-cycle members (the ADP view) without the principle table. #[test] diff --git a/crates/code-ranker-graph/metrics/builtin.toml b/crates/code-ranker-graph/metrics/builtin.toml index d10f50ab..5af0e7d5 100644 --- a/crates/code-ranker-graph/metrics/builtin.toml +++ b/crates/code-ranker-graph/metrics/builtin.toml @@ -45,23 +45,23 @@ omit_at = 0.0 # ── categories ──────────────────────────────────────────────────────────────── [categories.complexity] label = "Complexity" -description = "Code complexity metrics" +description = "per-function branching, nesting & size" [categories.halstead] label = "Halstead" -description = "Halstead software metrics" +description = "operator/operand vocabulary & derived effort" [categories.loc] label = "Lines of Code" -description = "Lines of code breakdown" +description = "physical line counts" [categories.maintainability] label = "Maintainability" -description = "Maintainability index" +description = "composite score" [categories.coupling] label = "Coupling" -description = "Internal coupling (Henry-Kafura)" +description = "how tightly modules depend on each other" # ── ast (tier-1, measured directly from the AST) ────────────────────────────── # Halstead base counts — emitted so the derived formulas (length, vocabulary, @@ -104,7 +104,7 @@ category = "halstead" [ast.spaces] value_type = "int" label = "Spaces" -name = "Unit count" +name = "Code units" description = "Unit count: the source file (1) plus each function / impl / trait / closure space. Feeds `cyclomatic`." direction = "lower_better" category = "complexity" @@ -157,6 +157,7 @@ category = "complexity" [ast.closures] value_type = "int" label = "Closures" +name = "Closures defined" description = "Number of closures defined in the unit." direction = "lower_better" category = "complexity" @@ -203,7 +204,7 @@ category = "loc" [fields.bugs] value_type = "float" label = "Bugs" -name = "Halstead bugs" +name = "Estimated bugs" short = "H.bugs" description = "Estimated delivered bugs — a rough predictor of defect density." formula_cel = "eta2 > 0.0 ? pow(effort, 2.0 / 3.0) / 3000.0 : 0.0" @@ -231,7 +232,7 @@ omit_at = 1.0 [fields.effort] value_type = "float" label = "Effort" -name = "Halstead effort" +name = "Implementation effort" short = "H.effort" description = "Mental effort to implement the algorithm." formula_cel = "eta2 > 0.0 ? (eta1 / 2.0) * (n2 / eta2) * volume : 0.0" @@ -243,7 +244,7 @@ category = "halstead" [fields.length] value_type = "float" label = "Length" -name = "Halstead length" +name = "Total tokens" short = "H.len" description = "Program length — total operator + operand occurrences." formula_cel = "n1 + n2" @@ -278,7 +279,7 @@ category = "maintainability" [fields.time] value_type = "float" label = "Time" -name = "Halstead time, s" +name = "Coding time (s)" short = "H.time(s)" description = "Estimated implementation time, in seconds." formula_cel = "effort / 18.0" @@ -290,7 +291,7 @@ category = "halstead" [fields.vocabulary] value_type = "float" label = "Vocabulary" -name = "Halstead vocabulary" +name = "Distinct symbols" short = "H.vocab" description = "Vocabulary — distinct operators + operands." formula_cel = "eta1 + eta2" @@ -302,7 +303,7 @@ category = "halstead" [fields.volume] value_type = "float" label = "Volume" -name = "Halstead volume" +name = "Code volume" short = "H.vol" description = "Algorithm size in bits, from distinct operators and operands." formula_cel = "vocabulary > 0.0 ? length * log2(vocabulary) : 0.0" @@ -320,7 +321,7 @@ category = "halstead" [fields.hk] value_type = "float" label = "HK" -name = "Henry–Kafura" +name = "God-object risk" short = "HK" description = "Henry-Kafura information-flow complexity: a module that is both a busy crossroads (high fan-in × fan-out) and large — the most expensive place in the codebase to change." formula_cel = "sloc * pow(fan_in * fan_out, 2.0)" @@ -339,25 +340,30 @@ abbreviate = true [coupling.fan_in] value_type = "int" label = "Fan-in" +name = "Incoming dependencies" description = "Many other units depend on this one, making it risky to change and a single point of failure — though some hubs (shared types) carry high fan-in legitimately." category = "coupling" [coupling.fan_out] value_type = "int" label = "Fan-out" +name = "Outgoing dependencies" description = "This unit depends on many others, so it breaks when any of them change and is hard to test in isolation." category = "coupling" [coupling.fan_out_external] value_type = "int" label = "Fan-out (external)" +name = "External dependencies" description = "Number of distinct external libraries this node depends on." category = "coupling" [coupling.cycle] value_type = "str" label = "Cycle" +name = "Dependency cycle" short = "Cycle" +category = "coupling" description = "Cycle kind this node participates in." # ── cycle kinds (computed by annotate_cycles) ───────────────────────────────── diff --git a/crates/code-ranker-graph/src/builtin_test.rs b/crates/code-ranker-graph/src/builtin_test.rs index cbf4e30c..19679a9e 100644 --- a/crates/code-ranker-graph/src/builtin_test.rs +++ b/crates/code-ranker-graph/src/builtin_test.rs @@ -78,9 +78,10 @@ fn spec_field_mapping_is_wire_compatible() { // formula_pretty → formula, formula_js → calc. assert_eq!(vol.formula.as_deref(), Some("length × log₂(vocabulary)")); assert_eq!(vol.calc.as_deref(), Some("length * Math.log2(vocabulary)")); - // name/short fall back to label where the TOML omits them. + // `short` falls back to `label` where the TOML omits it (closures sets no + // `short`); `name` is the spec's own value. let clo = &specs["closures"]; - assert_eq!(clo.name.as_deref(), Some("Closures")); + assert_eq!(clo.name.as_deref(), Some("Closures defined")); assert_eq!(clo.short.as_deref(), Some("Closures")); // multiline description re-encoded with <br>, no raw newlines. let cog = &specs["cognitive"]; diff --git a/crates/code-ranker-plugins/src/config/mod.rs b/crates/code-ranker-plugins/src/config/mod.rs index 826b405a..37f005d2 100644 --- a/crates/code-ranker-plugins/src/config/mod.rs +++ b/crates/code-ranker-plugins/src/config/mod.rs @@ -30,7 +30,10 @@ pub use specs::{ PrincipleCfg, SpecOverride, apply_spec_overrides, principles, resolved_principles, spec_overrides, }; -pub use views::{attr_key, edge_attributes, edge_kind_id, edge_kinds, node_attributes, node_kinds}; +pub use views::{ + attr_key, attribute_groups, edge_attributes, edge_kind_id, edge_kinds, node_attributes, + node_kinds, +}; #[cfg(test)] #[path = "../tests/config.rs"] diff --git a/crates/code-ranker-plugins/src/config/views.rs b/crates/code-ranker-plugins/src/config/views.rs index ea0b3cc7..e9243106 100644 --- a/crates/code-ranker-plugins/src/config/views.rs +++ b/crates/code-ranker-plugins/src/config/views.rs @@ -3,7 +3,7 @@ //! turns into its `levels()` spec, plus the `edge_kind_id` / `attr_key` lookups //! the structure builder tags edges/attrs with. -use code_ranker_plugin_api::level::{AttributeSpec, EdgeKindSpec, NodeKindSpec}; +use code_ranker_plugin_api::level::{AttributeGroup, AttributeSpec, EdgeKindSpec, NodeKindSpec}; use std::collections::BTreeMap; use toml::Table; @@ -76,3 +76,14 @@ pub fn edge_attributes(cfg: &Table) -> BTreeMap<String, AttributeSpec> { .map(|v| v.try_into().expect("[edge_attributes] shape")) .unwrap_or_default() } + +/// Read the `[attribute_groups]` table from a merged config as `key → +/// AttributeGroup` (empty if absent) — the language's own metric categories (e.g. +/// Rust groups `unsafe` / `items` under a "Rust-specific" category), the analogue +/// of the central `[categories.*]` in `builtin.toml`. +pub fn attribute_groups(cfg: &Table) -> BTreeMap<String, AttributeGroup> { + cfg.get("attribute_groups") + .cloned() + .map(|v| v.try_into().expect("[attribute_groups] shape")) + .unwrap_or_default() +} diff --git a/crates/code-ranker-plugins/src/defaults.toml b/crates/code-ranker-plugins/src/defaults.toml index 32288b32..2c248ba8 100644 --- a/crates/code-ranker-plugins/src/defaults.toml +++ b/crates/code-ranker-plugins/src/defaults.toml @@ -125,6 +125,8 @@ label = "Path" [node_attributes.loc] value_type = "int" label = "Lines" +name = "Total lines" +group = "loc" description = "Raw file line count, including blank and comment lines (unlike `sloc`). Large files tend to hold several responsibilities and are harder to review, test, and reuse." remediation = "Split by responsibility into smaller units, extract helpers, and separate data definitions from behavior. For an average breach, break up the largest units first (--top)." 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 7d7a1b70..6c3aa3bc 100644 --- a/crates/code-ranker-plugins/src/languages/c/tests/sample/code-ranker-report.json +++ b/crates/code-ranker-plugins/src/languages/c/tests/sample/code-ranker-report.json @@ -1,5 +1,5 @@ { - "command": "code-ranker report crates/code-ranker-plugins/src/languages/c/tests/sample --config crates/code-ranker-plugins/src/languages/c/tests/sample/code-ranker.toml --output.json.path=crates/code-ranker-plugins/src/languages/c/tests/sample/code-ranker-report.json", + "command": "code-ranker report crates/code-ranker-plugins/src/languages/c/tests/sample --config crates/code-ranker-plugins/src/languages/c/tests/sample/code-ranker.toml --output.json.path=crates/code-ranker-plugins/src/languages/c/tests/sample/code-ranker-report.json --output.mode quiet", "config_file": "crates/code-ranker-plugins/src/languages/c/tests/sample/code-ranker.toml", "generated_at": "1970-01-01T00:00:00Z", "git": { @@ -12,23 +12,23 @@ "files": { "attribute_groups": { "complexity": { - "description": "Code complexity metrics", + "description": "per-function branching, nesting & size", "label": "Complexity" }, "coupling": { - "description": "Internal coupling (Henry-Kafura)", + "description": "how tightly modules depend on each other", "label": "Coupling" }, "halstead": { - "description": "Halstead software metrics", + "description": "operator/operand vocabulary & derived effort", "label": "Halstead" }, "loc": { - "description": "Lines of code breakdown", + "description": "physical line counts", "label": "Lines of Code" }, "maintainability": { - "description": "Maintainability index", + "description": "composite score", "label": "Maintainability" } }, @@ -108,7 +108,7 @@ "formula": "effort^⅔ ÷ 3000", "group": "halstead", "label": "Bugs", - "name": "Halstead bugs", + "name": "Estimated bugs", "short": "H.bugs", "value_type": "float" }, @@ -148,7 +148,7 @@ "formula": "(eta1 ÷ 2) × (n2 ÷ eta2) × volume", "group": "halstead", "label": "Effort", - "name": "Halstead effort", + "name": "Implementation effort", "short": "H.effort", "value_type": "float" }, @@ -187,7 +187,7 @@ "description": "Many other units depend on this one, making it risky to change and a single point of failure — though some hubs (shared types) carry high fan-in legitimately.", "group": "coupling", "label": "Fan-in", - "name": "Fan-in", + "name": "Incoming dependencies", "short": "Fan-in", "value_type": "int" }, @@ -195,7 +195,7 @@ "description": "This unit depends on many others, so it breaks when any of them change and is hard to test in isolation.", "group": "coupling", "label": "Fan-out", - "name": "Fan-out", + "name": "Outgoing dependencies", "short": "Fan-out", "value_type": "int" }, @@ -203,7 +203,7 @@ "description": "Number of distinct external libraries this node depends on.", "group": "coupling", "label": "Fan-out (external)", - "name": "Fan-out (external)", + "name": "External dependencies", "short": "Fan-out (external)", "value_type": "int" }, @@ -215,7 +215,7 @@ "formula": "sloc × (fan_in × fan_out)²", "group": "coupling", "label": "HK", - "name": "Henry–Kafura", + "name": "God-object risk", "short": "HK", "value_type": "float" }, @@ -226,7 +226,7 @@ "formula": "n1 + n2", "group": "halstead", "label": "Length", - "name": "Halstead length", + "name": "Total tokens", "short": "H.len", "value_type": "float" }, @@ -240,7 +240,9 @@ }, "loc": { "description": "Raw file line count, including blank and comment lines (unlike `sloc`). Large files tend to hold several responsibilities and are harder to review, test, and reuse.", + "group": "loc", "label": "Lines", + "name": "Total lines", "remediation": "Split by responsibility into smaller units, extract helpers, and separate data definitions from behavior. For an average breach, break up the largest units first (--top).", "value_type": "int" }, @@ -297,7 +299,7 @@ "direction": "lower_better", "group": "complexity", "label": "Spaces", - "name": "Unit count", + "name": "Code units", "short": "Spaces", "value_type": "int" }, @@ -317,7 +319,7 @@ "formula": "effort ÷ 18", "group": "halstead", "label": "Time", - "name": "Halstead time, s", + "name": "Coding time (s)", "short": "H.time(s)", "value_type": "float" }, @@ -328,7 +330,7 @@ "formula": "eta1 + eta2", "group": "halstead", "label": "Vocabulary", - "name": "Halstead vocabulary", + "name": "Distinct symbols", "short": "H.vocab", "value_type": "float" }, @@ -339,7 +341,7 @@ "formula": "length × log₂(vocabulary)", "group": "halstead", "label": "Volume", - "name": "Halstead volume", + "name": "Code volume", "short": "H.vol", "value_type": "float" } 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 7ae388e2..d39ee75a 100644 --- a/crates/code-ranker-plugins/src/languages/cpp/tests/sample/code-ranker-report.json +++ b/crates/code-ranker-plugins/src/languages/cpp/tests/sample/code-ranker-report.json @@ -1,5 +1,5 @@ { - "command": "code-ranker report crates/code-ranker-plugins/src/languages/cpp/tests/sample --config crates/code-ranker-plugins/src/languages/cpp/tests/sample/code-ranker.toml --output.json.path=crates/code-ranker-plugins/src/languages/cpp/tests/sample/code-ranker-report.json", + "command": "code-ranker report crates/code-ranker-plugins/src/languages/cpp/tests/sample --config crates/code-ranker-plugins/src/languages/cpp/tests/sample/code-ranker.toml --output.json.path=crates/code-ranker-plugins/src/languages/cpp/tests/sample/code-ranker-report.json --output.mode quiet", "config_file": "crates/code-ranker-plugins/src/languages/cpp/tests/sample/code-ranker.toml", "generated_at": "1970-01-01T00:00:00Z", "git": { @@ -12,23 +12,23 @@ "files": { "attribute_groups": { "complexity": { - "description": "Code complexity metrics", + "description": "per-function branching, nesting & size", "label": "Complexity" }, "coupling": { - "description": "Internal coupling (Henry-Kafura)", + "description": "how tightly modules depend on each other", "label": "Coupling" }, "halstead": { - "description": "Halstead software metrics", + "description": "operator/operand vocabulary & derived effort", "label": "Halstead" }, "loc": { - "description": "Lines of code breakdown", + "description": "physical line counts", "label": "Lines of Code" }, "maintainability": { - "description": "Maintainability index", + "description": "composite score", "label": "Maintainability" } }, @@ -108,7 +108,7 @@ "formula": "effort^⅔ ÷ 3000", "group": "halstead", "label": "Bugs", - "name": "Halstead bugs", + "name": "Estimated bugs", "short": "H.bugs", "value_type": "float" }, @@ -125,7 +125,7 @@ "direction": "lower_better", "group": "complexity", "label": "Closures", - "name": "Closures", + "name": "Closures defined", "short": "Closures", "value_type": "int" }, @@ -157,7 +157,7 @@ "formula": "(eta1 ÷ 2) × (n2 ÷ eta2) × volume", "group": "halstead", "label": "Effort", - "name": "Halstead effort", + "name": "Implementation effort", "short": "H.effort", "value_type": "float" }, @@ -196,7 +196,7 @@ "description": "Many other units depend on this one, making it risky to change and a single point of failure — though some hubs (shared types) carry high fan-in legitimately.", "group": "coupling", "label": "Fan-in", - "name": "Fan-in", + "name": "Incoming dependencies", "short": "Fan-in", "value_type": "int" }, @@ -204,7 +204,7 @@ "description": "This unit depends on many others, so it breaks when any of them change and is hard to test in isolation.", "group": "coupling", "label": "Fan-out", - "name": "Fan-out", + "name": "Outgoing dependencies", "short": "Fan-out", "value_type": "int" }, @@ -212,7 +212,7 @@ "description": "Number of distinct external libraries this node depends on.", "group": "coupling", "label": "Fan-out (external)", - "name": "Fan-out (external)", + "name": "External dependencies", "short": "Fan-out (external)", "value_type": "int" }, @@ -224,7 +224,7 @@ "formula": "sloc × (fan_in × fan_out)²", "group": "coupling", "label": "HK", - "name": "Henry–Kafura", + "name": "God-object risk", "short": "HK", "value_type": "float" }, @@ -235,7 +235,7 @@ "formula": "n1 + n2", "group": "halstead", "label": "Length", - "name": "Halstead length", + "name": "Total tokens", "short": "H.len", "value_type": "float" }, @@ -249,7 +249,9 @@ }, "loc": { "description": "Raw file line count, including blank and comment lines (unlike `sloc`). Large files tend to hold several responsibilities and are harder to review, test, and reuse.", + "group": "loc", "label": "Lines", + "name": "Total lines", "remediation": "Split by responsibility into smaller units, extract helpers, and separate data definitions from behavior. For an average breach, break up the largest units first (--top).", "value_type": "int" }, @@ -306,7 +308,7 @@ "direction": "lower_better", "group": "complexity", "label": "Spaces", - "name": "Unit count", + "name": "Code units", "short": "Spaces", "value_type": "int" }, @@ -326,7 +328,7 @@ "formula": "effort ÷ 18", "group": "halstead", "label": "Time", - "name": "Halstead time, s", + "name": "Coding time (s)", "short": "H.time(s)", "value_type": "float" }, @@ -337,7 +339,7 @@ "formula": "eta1 + eta2", "group": "halstead", "label": "Vocabulary", - "name": "Halstead vocabulary", + "name": "Distinct symbols", "short": "H.vocab", "value_type": "float" }, @@ -348,7 +350,7 @@ "formula": "length × log₂(vocabulary)", "group": "halstead", "label": "Volume", - "name": "Halstead volume", + "name": "Code volume", "short": "H.vol", "value_type": "float" } 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 30c958cf..b5b4e4f1 100644 --- a/crates/code-ranker-plugins/src/languages/csharp/tests/sample/code-ranker-report.json +++ b/crates/code-ranker-plugins/src/languages/csharp/tests/sample/code-ranker-report.json @@ -1,5 +1,5 @@ { - "command": "code-ranker report crates/code-ranker-plugins/src/languages/csharp/tests/sample --config crates/code-ranker-plugins/src/languages/csharp/tests/sample/code-ranker.toml --output.json.path=crates/code-ranker-plugins/src/languages/csharp/tests/sample/code-ranker-report.json", + "command": "code-ranker report crates/code-ranker-plugins/src/languages/csharp/tests/sample --config crates/code-ranker-plugins/src/languages/csharp/tests/sample/code-ranker.toml --output.json.path=crates/code-ranker-plugins/src/languages/csharp/tests/sample/code-ranker-report.json --output.mode quiet", "config_file": "crates/code-ranker-plugins/src/languages/csharp/tests/sample/code-ranker.toml", "generated_at": "1970-01-01T00:00:00Z", "git": { @@ -12,23 +12,23 @@ "files": { "attribute_groups": { "complexity": { - "description": "Code complexity metrics", + "description": "per-function branching, nesting & size", "label": "Complexity" }, "coupling": { - "description": "Internal coupling (Henry-Kafura)", + "description": "how tightly modules depend on each other", "label": "Coupling" }, "halstead": { - "description": "Halstead software metrics", + "description": "operator/operand vocabulary & derived effort", "label": "Halstead" }, "loc": { - "description": "Lines of code breakdown", + "description": "physical line counts", "label": "Lines of Code" }, "maintainability": { - "description": "Maintainability index", + "description": "composite score", "label": "Maintainability" } }, @@ -96,7 +96,7 @@ "formula": "effort^⅔ ÷ 3000", "group": "halstead", "label": "Bugs", - "name": "Halstead bugs", + "name": "Estimated bugs", "short": "H.bugs", "value_type": "float" }, @@ -113,7 +113,7 @@ "direction": "lower_better", "group": "complexity", "label": "Closures", - "name": "Closures", + "name": "Closures defined", "short": "Closures", "value_type": "int" }, @@ -145,7 +145,7 @@ "formula": "(eta1 ÷ 2) × (n2 ÷ eta2) × volume", "group": "halstead", "label": "Effort", - "name": "Halstead effort", + "name": "Implementation effort", "short": "H.effort", "value_type": "float" }, @@ -184,7 +184,7 @@ "description": "Many other units depend on this one, making it risky to change and a single point of failure — though some hubs (shared types) carry high fan-in legitimately.", "group": "coupling", "label": "Fan-in", - "name": "Fan-in", + "name": "Incoming dependencies", "short": "Fan-in", "value_type": "int" }, @@ -192,7 +192,7 @@ "description": "This unit depends on many others, so it breaks when any of them change and is hard to test in isolation.", "group": "coupling", "label": "Fan-out", - "name": "Fan-out", + "name": "Outgoing dependencies", "short": "Fan-out", "value_type": "int" }, @@ -200,7 +200,7 @@ "description": "Number of distinct external libraries this node depends on.", "group": "coupling", "label": "Fan-out (external)", - "name": "Fan-out (external)", + "name": "External dependencies", "short": "Fan-out (external)", "value_type": "int" }, @@ -212,7 +212,7 @@ "formula": "sloc × (fan_in × fan_out)²", "group": "coupling", "label": "HK", - "name": "Henry–Kafura", + "name": "God-object risk", "short": "HK", "value_type": "float" }, @@ -223,7 +223,7 @@ "formula": "n1 + n2", "group": "halstead", "label": "Length", - "name": "Halstead length", + "name": "Total tokens", "short": "H.len", "value_type": "float" }, @@ -237,7 +237,9 @@ }, "loc": { "description": "Raw file line count, including blank and comment lines (unlike `sloc`). Large files tend to hold several responsibilities and are harder to review, test, and reuse.", + "group": "loc", "label": "Lines", + "name": "Total lines", "remediation": "Split by responsibility into smaller units, extract helpers, and separate data definitions from behavior. For an average breach, break up the largest units first (--top).", "value_type": "int" }, @@ -294,7 +296,7 @@ "direction": "lower_better", "group": "complexity", "label": "Spaces", - "name": "Unit count", + "name": "Code units", "short": "Spaces", "value_type": "int" }, @@ -314,7 +316,7 @@ "formula": "effort ÷ 18", "group": "halstead", "label": "Time", - "name": "Halstead time, s", + "name": "Coding time (s)", "short": "H.time(s)", "value_type": "float" }, @@ -325,7 +327,7 @@ "formula": "eta1 + eta2", "group": "halstead", "label": "Vocabulary", - "name": "Halstead vocabulary", + "name": "Distinct symbols", "short": "H.vocab", "value_type": "float" }, @@ -336,7 +338,7 @@ "formula": "length × log₂(vocabulary)", "group": "halstead", "label": "Volume", - "name": "Halstead volume", + "name": "Code volume", "short": "H.vol", "value_type": "float" } 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 c047feb1..f32c85dc 100644 --- a/crates/code-ranker-plugins/src/languages/go/tests/sample/code-ranker-report.json +++ b/crates/code-ranker-plugins/src/languages/go/tests/sample/code-ranker-report.json @@ -1,5 +1,5 @@ { - "command": "code-ranker report crates/code-ranker-plugins/src/languages/go/tests/sample --config crates/code-ranker-plugins/src/languages/go/tests/sample/code-ranker.toml --output.json.path=crates/code-ranker-plugins/src/languages/go/tests/sample/code-ranker-report.json", + "command": "code-ranker report crates/code-ranker-plugins/src/languages/go/tests/sample --config crates/code-ranker-plugins/src/languages/go/tests/sample/code-ranker.toml --output.json.path=crates/code-ranker-plugins/src/languages/go/tests/sample/code-ranker-report.json --output.mode quiet", "config_file": "crates/code-ranker-plugins/src/languages/go/tests/sample/code-ranker.toml", "generated_at": "1970-01-01T00:00:00Z", "git": { @@ -12,23 +12,23 @@ "files": { "attribute_groups": { "complexity": { - "description": "Code complexity metrics", + "description": "per-function branching, nesting & size", "label": "Complexity" }, "coupling": { - "description": "Internal coupling (Henry-Kafura)", + "description": "how tightly modules depend on each other", "label": "Coupling" }, "halstead": { - "description": "Halstead software metrics", + "description": "operator/operand vocabulary & derived effort", "label": "Halstead" }, "loc": { - "description": "Lines of code breakdown", + "description": "physical line counts", "label": "Lines of Code" }, "maintainability": { - "description": "Maintainability index", + "description": "composite score", "label": "Maintainability" } }, @@ -96,7 +96,7 @@ "formula": "effort^⅔ ÷ 3000", "group": "halstead", "label": "Bugs", - "name": "Halstead bugs", + "name": "Estimated bugs", "short": "H.bugs", "value_type": "float" }, @@ -113,7 +113,7 @@ "direction": "lower_better", "group": "complexity", "label": "Closures", - "name": "Closures", + "name": "Closures defined", "short": "Closures", "value_type": "int" }, @@ -145,7 +145,7 @@ "formula": "(eta1 ÷ 2) × (n2 ÷ eta2) × volume", "group": "halstead", "label": "Effort", - "name": "Halstead effort", + "name": "Implementation effort", "short": "H.effort", "value_type": "float" }, @@ -184,7 +184,7 @@ "description": "Many other units depend on this one, making it risky to change and a single point of failure — though some hubs (shared types) carry high fan-in legitimately.", "group": "coupling", "label": "Fan-in", - "name": "Fan-in", + "name": "Incoming dependencies", "short": "Fan-in", "value_type": "int" }, @@ -192,7 +192,7 @@ "description": "This unit depends on many others, so it breaks when any of them change and is hard to test in isolation.", "group": "coupling", "label": "Fan-out", - "name": "Fan-out", + "name": "Outgoing dependencies", "short": "Fan-out", "value_type": "int" }, @@ -200,7 +200,7 @@ "description": "Number of distinct external libraries this node depends on.", "group": "coupling", "label": "Fan-out (external)", - "name": "Fan-out (external)", + "name": "External dependencies", "short": "Fan-out (external)", "value_type": "int" }, @@ -212,7 +212,7 @@ "formula": "sloc × (fan_in × fan_out)²", "group": "coupling", "label": "HK", - "name": "Henry–Kafura", + "name": "God-object risk", "short": "HK", "value_type": "float" }, @@ -223,7 +223,7 @@ "formula": "n1 + n2", "group": "halstead", "label": "Length", - "name": "Halstead length", + "name": "Total tokens", "short": "H.len", "value_type": "float" }, @@ -237,7 +237,9 @@ }, "loc": { "description": "Raw file line count, including blank and comment lines (unlike `sloc`). Large files tend to hold several responsibilities and are harder to review, test, and reuse.", + "group": "loc", "label": "Lines", + "name": "Total lines", "remediation": "Split by responsibility into smaller units, extract helpers, and separate data definitions from behavior. For an average breach, break up the largest units first (--top).", "value_type": "int" }, @@ -294,7 +296,7 @@ "direction": "lower_better", "group": "complexity", "label": "Spaces", - "name": "Unit count", + "name": "Code units", "short": "Spaces", "value_type": "int" }, @@ -314,7 +316,7 @@ "formula": "effort ÷ 18", "group": "halstead", "label": "Time", - "name": "Halstead time, s", + "name": "Coding time (s)", "short": "H.time(s)", "value_type": "float" }, @@ -325,7 +327,7 @@ "formula": "eta1 + eta2", "group": "halstead", "label": "Vocabulary", - "name": "Halstead vocabulary", + "name": "Distinct symbols", "short": "H.vocab", "value_type": "float" }, @@ -336,7 +338,7 @@ "formula": "length × log₂(vocabulary)", "group": "halstead", "label": "Volume", - "name": "Halstead volume", + "name": "Code volume", "short": "H.vol", "value_type": "float" } diff --git a/crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker-report.json b/crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker-report.json index 8b78e0aa..576d77a1 100644 --- a/crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker-report.json +++ b/crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker-report.json @@ -1,5 +1,5 @@ { - "command": "code-ranker report crates/code-ranker-plugins/src/languages/javascript/tests/sample --config crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker.toml --output.json.path=crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker-report.json", + "command": "code-ranker report crates/code-ranker-plugins/src/languages/javascript/tests/sample --config crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker.toml --output.json.path=crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker-report.json --output.mode quiet", "config_file": "crates/code-ranker-plugins/src/languages/javascript/tests/sample/code-ranker.toml", "generated_at": "1970-01-01T00:00:00Z", "git": { @@ -12,23 +12,23 @@ "files": { "attribute_groups": { "complexity": { - "description": "Code complexity metrics", + "description": "per-function branching, nesting & size", "label": "Complexity" }, "coupling": { - "description": "Internal coupling (Henry-Kafura)", + "description": "how tightly modules depend on each other", "label": "Coupling" }, "halstead": { - "description": "Halstead software metrics", + "description": "operator/operand vocabulary & derived effort", "label": "Halstead" }, "loc": { - "description": "Lines of code breakdown", + "description": "physical line counts", "label": "Lines of Code" }, "maintainability": { - "description": "Maintainability index", + "description": "composite score", "label": "Maintainability" } }, @@ -171,7 +171,7 @@ "formula": "effort^⅔ ÷ 3000", "group": "halstead", "label": "Bugs", - "name": "Halstead bugs", + "name": "Estimated bugs", "short": "H.bugs", "value_type": "float" }, @@ -188,7 +188,7 @@ "direction": "lower_better", "group": "complexity", "label": "Closures", - "name": "Closures", + "name": "Closures defined", "short": "Closures", "value_type": "int" }, @@ -203,8 +203,9 @@ }, "cycle": { "description": "Cycle kind this node participates in.", + "group": "coupling", "label": "Cycle", - "name": "Cycle", + "name": "Dependency cycle", "short": "Cycle", "value_type": "str" }, @@ -227,7 +228,7 @@ "formula": "(eta1 ÷ 2) × (n2 ÷ eta2) × volume", "group": "halstead", "label": "Effort", - "name": "Halstead effort", + "name": "Implementation effort", "short": "H.effort", "value_type": "float" }, @@ -266,7 +267,7 @@ "description": "Many other units depend on this one, making it risky to change and a single point of failure — though some hubs (shared types) carry high fan-in legitimately.", "group": "coupling", "label": "Fan-in", - "name": "Fan-in", + "name": "Incoming dependencies", "short": "Fan-in", "value_type": "int" }, @@ -274,7 +275,7 @@ "description": "This unit depends on many others, so it breaks when any of them change and is hard to test in isolation.", "group": "coupling", "label": "Fan-out", - "name": "Fan-out", + "name": "Outgoing dependencies", "short": "Fan-out", "value_type": "int" }, @@ -282,7 +283,7 @@ "description": "Number of distinct external libraries this node depends on.", "group": "coupling", "label": "Fan-out (external)", - "name": "Fan-out (external)", + "name": "External dependencies", "short": "Fan-out (external)", "value_type": "int" }, @@ -294,7 +295,7 @@ "formula": "sloc × (fan_in × fan_out)²", "group": "coupling", "label": "HK", - "name": "Henry–Kafura", + "name": "God-object risk", "short": "HK", "value_type": "float" }, @@ -305,7 +306,7 @@ "formula": "n1 + n2", "group": "halstead", "label": "Length", - "name": "Halstead length", + "name": "Total tokens", "short": "H.len", "value_type": "float" }, @@ -319,7 +320,9 @@ }, "loc": { "description": "Raw file line count, including blank and comment lines (unlike `sloc`). Large files tend to hold several responsibilities and are harder to review, test, and reuse.", + "group": "loc", "label": "Lines", + "name": "Total lines", "remediation": "Split by responsibility into smaller units, extract helpers, and separate data definitions from behavior. For an average breach, break up the largest units first (--top).", "value_type": "int" }, @@ -376,7 +379,7 @@ "direction": "lower_better", "group": "complexity", "label": "Spaces", - "name": "Unit count", + "name": "Code units", "short": "Spaces", "value_type": "int" }, @@ -396,7 +399,7 @@ "formula": "effort ÷ 18", "group": "halstead", "label": "Time", - "name": "Halstead time, s", + "name": "Coding time (s)", "short": "H.time(s)", "value_type": "float" }, @@ -411,7 +414,7 @@ "formula": "eta1 + eta2", "group": "halstead", "label": "Vocabulary", - "name": "Halstead vocabulary", + "name": "Distinct symbols", "short": "H.vocab", "value_type": "float" }, @@ -422,7 +425,7 @@ "formula": "length × log₂(vocabulary)", "group": "halstead", "label": "Volume", - "name": "Halstead volume", + "name": "Code volume", "short": "H.vol", "value_type": "float" } diff --git a/crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker-report.json b/crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker-report.json index 6ee928dd..7a484fa1 100644 --- a/crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker-report.json +++ b/crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker-report.json @@ -1,5 +1,5 @@ { - "command": "code-ranker report crates/code-ranker-plugins/src/languages/markdown/tests/sample --config crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker.toml --output.json.path=crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker-report.json", + "command": "code-ranker report crates/code-ranker-plugins/src/languages/markdown/tests/sample --config crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker.toml --output.json.path=crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker-report.json --output.mode quiet", "config_file": "crates/code-ranker-plugins/src/languages/markdown/tests/sample/code-ranker.toml", "generated_at": "1970-01-01T00:00:00Z", "git": { @@ -12,8 +12,12 @@ "files": { "attribute_groups": { "coupling": { - "description": "Internal coupling (Henry-Kafura)", + "description": "how tightly modules depend on each other", "label": "Coupling" + }, + "loc": { + "description": "physical line counts", + "label": "Lines of Code" } }, "cycle_kinds": { @@ -88,8 +92,9 @@ }, "cycle": { "description": "Cycle kind this node participates in.", + "group": "coupling", "label": "Cycle", - "name": "Cycle", + "name": "Dependency cycle", "short": "Cycle", "value_type": "str" }, @@ -97,7 +102,7 @@ "description": "Many other units depend on this one, making it risky to change and a single point of failure — though some hubs (shared types) carry high fan-in legitimately.", "group": "coupling", "label": "Fan-in", - "name": "Fan-in", + "name": "Incoming dependencies", "short": "Fan-in", "value_type": "int" }, @@ -105,7 +110,7 @@ "description": "This unit depends on many others, so it breaks when any of them change and is hard to test in isolation.", "group": "coupling", "label": "Fan-out", - "name": "Fan-out", + "name": "Outgoing dependencies", "short": "Fan-out", "value_type": "int" }, @@ -125,7 +130,9 @@ }, "loc": { "description": "Raw file line count, including blank and comment lines (unlike `sloc`). Large files tend to hold several responsibilities and are harder to review, test, and reuse.", + "group": "loc", "label": "Lines", + "name": "Total lines", "remediation": "Split by responsibility into smaller units, extract helpers, and separate data definitions from behavior. For an average breach, break up the largest units first (--top).", "value_type": "int" }, 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 28db4664..c098d849 100644 --- a/crates/code-ranker-plugins/src/languages/python/tests/sample/code-ranker-report.json +++ b/crates/code-ranker-plugins/src/languages/python/tests/sample/code-ranker-report.json @@ -1,5 +1,5 @@ { - "command": "code-ranker report crates/code-ranker-plugins/src/languages/python/tests/sample --config crates/code-ranker-plugins/src/languages/python/tests/sample/code-ranker.toml --output.json.path=crates/code-ranker-plugins/src/languages/python/tests/sample/code-ranker-report.json", + "command": "code-ranker report crates/code-ranker-plugins/src/languages/python/tests/sample --config crates/code-ranker-plugins/src/languages/python/tests/sample/code-ranker.toml --output.json.path=crates/code-ranker-plugins/src/languages/python/tests/sample/code-ranker-report.json --output.mode quiet", "config_file": "crates/code-ranker-plugins/src/languages/python/tests/sample/code-ranker.toml", "generated_at": "1970-01-01T00:00:00Z", "git": { @@ -12,23 +12,23 @@ "files": { "attribute_groups": { "complexity": { - "description": "Code complexity metrics", + "description": "per-function branching, nesting & size", "label": "Complexity" }, "coupling": { - "description": "Internal coupling (Henry-Kafura)", + "description": "how tightly modules depend on each other", "label": "Coupling" }, "halstead": { - "description": "Halstead software metrics", + "description": "operator/operand vocabulary & derived effort", "label": "Halstead" }, "loc": { - "description": "Lines of code breakdown", + "description": "physical line counts", "label": "Lines of Code" }, "maintainability": { - "description": "Maintainability index", + "description": "composite score", "label": "Maintainability" } }, @@ -225,7 +225,7 @@ "formula": "effort^⅔ ÷ 3000", "group": "halstead", "label": "Bugs", - "name": "Halstead bugs", + "name": "Estimated bugs", "short": "H.bugs", "value_type": "float" }, @@ -242,7 +242,7 @@ "direction": "lower_better", "group": "complexity", "label": "Closures", - "name": "Closures", + "name": "Closures defined", "short": "Closures", "value_type": "int" }, @@ -257,8 +257,9 @@ }, "cycle": { "description": "Cycle kind this node participates in.", + "group": "coupling", "label": "Cycle", - "name": "Cycle", + "name": "Dependency cycle", "short": "Cycle", "value_type": "str" }, @@ -281,7 +282,7 @@ "formula": "(eta1 ÷ 2) × (n2 ÷ eta2) × volume", "group": "halstead", "label": "Effort", - "name": "Halstead effort", + "name": "Implementation effort", "short": "H.effort", "value_type": "float" }, @@ -320,7 +321,7 @@ "description": "Many other units depend on this one, making it risky to change and a single point of failure — though some hubs (shared types) carry high fan-in legitimately.", "group": "coupling", "label": "Fan-in", - "name": "Fan-in", + "name": "Incoming dependencies", "short": "Fan-in", "value_type": "int" }, @@ -328,7 +329,7 @@ "description": "This unit depends on many others, so it breaks when any of them change and is hard to test in isolation.", "group": "coupling", "label": "Fan-out", - "name": "Fan-out", + "name": "Outgoing dependencies", "short": "Fan-out", "value_type": "int" }, @@ -336,7 +337,7 @@ "description": "Number of distinct external libraries this node depends on.", "group": "coupling", "label": "Fan-out (external)", - "name": "Fan-out (external)", + "name": "External dependencies", "short": "Fan-out (external)", "value_type": "int" }, @@ -348,7 +349,7 @@ "formula": "sloc × (fan_in × fan_out)²", "group": "coupling", "label": "HK", - "name": "Henry–Kafura", + "name": "God-object risk", "short": "HK", "value_type": "float" }, @@ -359,7 +360,7 @@ "formula": "n1 + n2", "group": "halstead", "label": "Length", - "name": "Halstead length", + "name": "Total tokens", "short": "H.len", "value_type": "float" }, @@ -373,7 +374,9 @@ }, "loc": { "description": "Raw file line count, including blank and comment lines (unlike `sloc`). Large files tend to hold several responsibilities and are harder to review, test, and reuse.", + "group": "loc", "label": "Lines", + "name": "Total lines", "remediation": "Split by responsibility into smaller units, extract helpers, and separate data definitions from behavior. For an average breach, break up the largest units first (--top).", "value_type": "int" }, @@ -430,7 +433,7 @@ "direction": "lower_better", "group": "complexity", "label": "Spaces", - "name": "Unit count", + "name": "Code units", "short": "Spaces", "value_type": "int" }, @@ -450,7 +453,7 @@ "formula": "effort ÷ 18", "group": "halstead", "label": "Time", - "name": "Halstead time, s", + "name": "Coding time (s)", "short": "H.time(s)", "value_type": "float" }, @@ -465,7 +468,7 @@ "formula": "eta1 + eta2", "group": "halstead", "label": "Vocabulary", - "name": "Halstead vocabulary", + "name": "Distinct symbols", "short": "H.vocab", "value_type": "float" }, @@ -476,7 +479,7 @@ "formula": "length × log₂(vocabulary)", "group": "halstead", "label": "Volume", - "name": "Halstead volume", + "name": "Code volume", "short": "H.vol", "value_type": "float" } diff --git a/crates/code-ranker-plugins/src/languages/rust/config.toml b/crates/code-ranker-plugins/src/languages/rust/config.toml index d5261477..4ed9eff6 100644 --- a/crates/code-ranker-plugins/src/languages/rust/config.toml +++ b/crates/code-ranker-plugins/src/languages/rust/config.toml @@ -295,6 +295,13 @@ description = "Total operands (N₂): every operand occurrence counted with repe # `path`/`loc`/`visibility`/`external` come from `defaults.toml`; Rust adds these. # ────────────────────────────────────────────────────────────────────────────── +# Rust's own metric category — groups the language-specific attributes below under +# one "Rust-specific" heading (the per-language analogue of `builtin.toml`'s central +# `[categories.*]`), so `docs` lists them together instead of as "(uncategorized)". +[attribute_groups.rust] +label = "Rust-specific" +description = "unsafe, items, traits & other Rust-only facts" + [node_attributes.crate] value_type = "str" label = "Crate" @@ -306,13 +313,16 @@ label = "Version" [node_attributes.items] value_type = "int" label = "Items" +group = "rust" description = "Number of top-level items (`fn` / `struct` / `enum` / `impl` / `trait` / `mod` / `const` / …) defined in the file — a structural size signal complementary to line counts." remediation = "Split the file by responsibility into focused modules; move large impls or trait clusters into their own files." [node_attributes.unsafe] value_type = "int" label = "Unsafe" +name = "Unsafe blocks" short = "Unsafe" +group = "rust" description = "Count of `unsafe` blocks and `unsafe fn`/`impl`/`trait` declarations in production code (test items are excluded). Syntactic count: `unsafe` inside a macro body is not seen, and the figure is not type-checked." remediation = "Encapsulate each `unsafe` block behind a safe, documented abstraction with checked invariants; minimize the unsafe surface and cover it with tests." direction = "lower_better" @@ -323,31 +333,37 @@ direction = "lower_better" [node_attributes.derives] value_type = "str" label = "Derives" +group = "rust" description = "Names of the `#[derive(...)]` traits used in the file (e.g. `Serialize,Debug`), production code only." [node_attributes.macros] value_type = "str" label = "Macros" +group = "rust" description = "Names of macros invoked in the file (e.g. `println,vec`), production code only." [node_attributes.attrs] value_type = "str" label = "Attributes" +group = "rust" description = "Names of attributes (other than `derive`) applied in the file (e.g. `tokio,serde`), production code only." [node_attributes.imports] value_type = "str" label = "Imports" +group = "rust" description = "Qualified paths the file references (≥2 segments, e.g. `http::StatusCode,std::fmt`), production code only." [node_attributes.types] value_type = "str" label = "Types" +group = "rust" description = "Names of types defined in the file (struct / enum / type alias)." [node_attributes.traits] value_type = "str" label = "Traits" +group = "rust" description = "Names of traits defined in the file." [edge_attributes.visibility] diff --git a/crates/code-ranker-plugins/src/languages/rust/mod.rs b/crates/code-ranker-plugins/src/languages/rust/mod.rs index eb93327f..0925a607 100644 --- a/crates/code-ranker-plugins/src/languages/rust/mod.rs +++ b/crates/code-ranker-plugins/src/languages/rust/mod.rs @@ -77,7 +77,7 @@ impl LanguagePlugin for RustPlugin { edge_kinds, node_attributes, edge_attributes, - attribute_groups: BTreeMap::new(), + attribute_groups: crate::config::attribute_groups(&CONFIG), node_kinds: default_node_kinds(), cycle_kinds: default_cycle_kinds(), // Cluster the diagram by the owning crate (compilation unit), not by 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 50ff07be..6946346c 100644 --- a/crates/code-ranker-plugins/src/languages/rust/tests/sample/code-ranker-report.json +++ b/crates/code-ranker-plugins/src/languages/rust/tests/sample/code-ranker-report.json @@ -1,5 +1,5 @@ { - "command": "code-ranker report crates/code-ranker-plugins/src/languages/rust/tests/sample --config crates/code-ranker-plugins/src/languages/rust/tests/sample/code-ranker.toml --output.json.path=crates/code-ranker-plugins/src/languages/rust/tests/sample/code-ranker-report.json", + "command": "code-ranker report crates/code-ranker-plugins/src/languages/rust/tests/sample --config crates/code-ranker-plugins/src/languages/rust/tests/sample/code-ranker.toml --output.json.path=crates/code-ranker-plugins/src/languages/rust/tests/sample/code-ranker-report.json --output.mode quiet", "config_file": "crates/code-ranker-plugins/src/languages/rust/tests/sample/code-ranker.toml", "generated_at": "1970-01-01T00:00:00Z", "git": { @@ -12,24 +12,28 @@ "files": { "attribute_groups": { "complexity": { - "description": "Code complexity metrics", + "description": "per-function branching, nesting & size", "label": "Complexity" }, "coupling": { - "description": "Internal coupling (Henry-Kafura)", + "description": "how tightly modules depend on each other", "label": "Coupling" }, "halstead": { - "description": "Halstead software metrics", + "description": "operator/operand vocabulary & derived effort", "label": "Halstead" }, "loc": { - "description": "Lines of code breakdown", + "description": "physical line counts", "label": "Lines of Code" }, "maintainability": { - "description": "Maintainability index", + "description": "composite score", "label": "Maintainability" + }, + "rust": { + "description": "unsafe, items, traits & other Rust-only facts", + "label": "Rust-specific" } }, "cycle_kinds": { @@ -367,6 +371,7 @@ }, "attrs": { "description": "Names of attributes (other than `derive`) applied in the file (e.g. `tokio,serde`), production code only.", + "group": "rust", "label": "Attributes", "value_type": "str" }, @@ -394,7 +399,7 @@ "formula": "effort^⅔ ÷ 3000", "group": "halstead", "label": "Bugs", - "name": "Halstead bugs", + "name": "Estimated bugs", "short": "H.bugs", "value_type": "float" }, @@ -411,7 +416,7 @@ "direction": "lower_better", "group": "complexity", "label": "Closures", - "name": "Closures", + "name": "Closures defined", "short": "Closures", "value_type": "int" }, @@ -430,8 +435,9 @@ }, "cycle": { "description": "Cycle kind this node participates in.", + "group": "coupling", "label": "Cycle", - "name": "Cycle", + "name": "Dependency cycle", "short": "Cycle", "value_type": "str" }, @@ -449,6 +455,7 @@ }, "derives": { "description": "Names of the `#[derive(...)]` traits used in the file (e.g. `Serialize,Debug`), production code only.", + "group": "rust", "label": "Derives", "value_type": "str" }, @@ -459,7 +466,7 @@ "formula": "(eta1 ÷ 2) × (n2 ÷ eta2) × volume", "group": "halstead", "label": "Effort", - "name": "Halstead effort", + "name": "Implementation effort", "short": "H.effort", "value_type": "float" }, @@ -498,7 +505,7 @@ "description": "Many other units depend on this one, making it risky to change and a single point of failure — though some hubs (shared types) carry high fan-in legitimately.", "group": "coupling", "label": "Fan-in", - "name": "Fan-in", + "name": "Incoming dependencies", "short": "Fan-in", "value_type": "int" }, @@ -506,7 +513,7 @@ "description": "This unit depends on many others, so it breaks when any of them change and is hard to test in isolation.", "group": "coupling", "label": "Fan-out", - "name": "Fan-out", + "name": "Outgoing dependencies", "short": "Fan-out", "value_type": "int" }, @@ -514,7 +521,7 @@ "description": "Number of distinct external libraries this node depends on.", "group": "coupling", "label": "Fan-out (external)", - "name": "Fan-out (external)", + "name": "External dependencies", "short": "Fan-out (external)", "value_type": "int" }, @@ -526,17 +533,19 @@ "formula": "sloc × (fan_in × fan_out)²", "group": "coupling", "label": "HK", - "name": "Henry–Kafura", + "name": "God-object risk", "short": "HK", "value_type": "float" }, "imports": { "description": "Qualified paths the file references (≥2 segments, e.g. `http::StatusCode,std::fmt`), production code only.", + "group": "rust", "label": "Imports", "value_type": "str" }, "items": { "description": "Number of top-level items (`fn` / `struct` / `enum` / `impl` / `trait` / `mod` / `const` / …) defined in the file — a structural size signal complementary to line counts.", + "group": "rust", "label": "Items", "remediation": "Split the file by responsibility into focused modules; move large impls or trait clusters into their own files.", "value_type": "int" @@ -548,7 +557,7 @@ "formula": "n1 + n2", "group": "halstead", "label": "Length", - "name": "Halstead length", + "name": "Total tokens", "short": "H.len", "value_type": "float" }, @@ -562,12 +571,15 @@ }, "loc": { "description": "Raw file line count, including blank and comment lines (unlike `sloc`). Large files tend to hold several responsibilities and are harder to review, test, and reuse.", + "group": "loc", "label": "Lines", + "name": "Total lines", "remediation": "Split by responsibility into smaller units, extract helpers, and separate data definitions from behavior. For an average breach, break up the largest units first (--top).", "value_type": "int" }, "macros": { "description": "Names of macros invoked in the file (e.g. `println,vec`), production code only.", + "group": "rust", "label": "Macros", "value_type": "str" }, @@ -628,7 +640,7 @@ "direction": "lower_better", "group": "complexity", "label": "Spaces", - "name": "Unit count", + "name": "Code units", "short": "Spaces", "value_type": "int" }, @@ -648,7 +660,7 @@ "formula": "effort ÷ 18", "group": "halstead", "label": "Time", - "name": "Halstead time, s", + "name": "Coding time (s)", "short": "H.time(s)", "value_type": "float" }, @@ -662,13 +674,16 @@ }, "types": { "description": "Names of types defined in the file (struct / enum / type alias).", + "group": "rust", "label": "Types", "value_type": "str" }, "unsafe": { "description": "Count of `unsafe` blocks and `unsafe fn`/`impl`/`trait` declarations in production code (test items are excluded). Syntactic count: `unsafe` inside a macro body is not seen, and the figure is not type-checked.", "direction": "lower_better", + "group": "rust", "label": "Unsafe", + "name": "Unsafe blocks", "remediation": "Encapsulate each `unsafe` block behind a safe, documented abstraction with checked invariants; minimize the unsafe surface and cover it with tests.", "short": "Unsafe", "value_type": "int" @@ -688,7 +703,7 @@ "formula": "eta1 + eta2", "group": "halstead", "label": "Vocabulary", - "name": "Halstead vocabulary", + "name": "Distinct symbols", "short": "H.vocab", "value_type": "float" }, @@ -699,7 +714,7 @@ "formula": "length × log₂(vocabulary)", "group": "halstead", "label": "Volume", - "name": "Halstead volume", + "name": "Code volume", "short": "H.vol", "value_type": "float" } diff --git a/crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker-report.json b/crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker-report.json index ee69ba74..21747793 100644 --- a/crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker-report.json +++ b/crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker-report.json @@ -1,5 +1,5 @@ { - "command": "code-ranker report crates/code-ranker-plugins/src/languages/typescript/tests/sample --config crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker.toml --output.json.path=crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker-report.json", + "command": "code-ranker report crates/code-ranker-plugins/src/languages/typescript/tests/sample --config crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker.toml --output.json.path=crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker-report.json --output.mode quiet", "config_file": "crates/code-ranker-plugins/src/languages/typescript/tests/sample/code-ranker.toml", "generated_at": "1970-01-01T00:00:00Z", "git": { @@ -12,23 +12,23 @@ "files": { "attribute_groups": { "complexity": { - "description": "Code complexity metrics", + "description": "per-function branching, nesting & size", "label": "Complexity" }, "coupling": { - "description": "Internal coupling (Henry-Kafura)", + "description": "how tightly modules depend on each other", "label": "Coupling" }, "halstead": { - "description": "Halstead software metrics", + "description": "operator/operand vocabulary & derived effort", "label": "Halstead" }, "loc": { - "description": "Lines of code breakdown", + "description": "physical line counts", "label": "Lines of Code" }, "maintainability": { - "description": "Maintainability index", + "description": "composite score", "label": "Maintainability" } }, @@ -177,7 +177,7 @@ "formula": "effort^⅔ ÷ 3000", "group": "halstead", "label": "Bugs", - "name": "Halstead bugs", + "name": "Estimated bugs", "short": "H.bugs", "value_type": "float" }, @@ -194,7 +194,7 @@ "direction": "lower_better", "group": "complexity", "label": "Closures", - "name": "Closures", + "name": "Closures defined", "short": "Closures", "value_type": "int" }, @@ -209,8 +209,9 @@ }, "cycle": { "description": "Cycle kind this node participates in.", + "group": "coupling", "label": "Cycle", - "name": "Cycle", + "name": "Dependency cycle", "short": "Cycle", "value_type": "str" }, @@ -233,7 +234,7 @@ "formula": "(eta1 ÷ 2) × (n2 ÷ eta2) × volume", "group": "halstead", "label": "Effort", - "name": "Halstead effort", + "name": "Implementation effort", "short": "H.effort", "value_type": "float" }, @@ -272,7 +273,7 @@ "description": "Many other units depend on this one, making it risky to change and a single point of failure — though some hubs (shared types) carry high fan-in legitimately.", "group": "coupling", "label": "Fan-in", - "name": "Fan-in", + "name": "Incoming dependencies", "short": "Fan-in", "value_type": "int" }, @@ -280,7 +281,7 @@ "description": "This unit depends on many others, so it breaks when any of them change and is hard to test in isolation.", "group": "coupling", "label": "Fan-out", - "name": "Fan-out", + "name": "Outgoing dependencies", "short": "Fan-out", "value_type": "int" }, @@ -288,7 +289,7 @@ "description": "Number of distinct external libraries this node depends on.", "group": "coupling", "label": "Fan-out (external)", - "name": "Fan-out (external)", + "name": "External dependencies", "short": "Fan-out (external)", "value_type": "int" }, @@ -300,7 +301,7 @@ "formula": "sloc × (fan_in × fan_out)²", "group": "coupling", "label": "HK", - "name": "Henry–Kafura", + "name": "God-object risk", "short": "HK", "value_type": "float" }, @@ -311,7 +312,7 @@ "formula": "n1 + n2", "group": "halstead", "label": "Length", - "name": "Halstead length", + "name": "Total tokens", "short": "H.len", "value_type": "float" }, @@ -325,7 +326,9 @@ }, "loc": { "description": "Raw file line count, including blank and comment lines (unlike `sloc`). Large files tend to hold several responsibilities and are harder to review, test, and reuse.", + "group": "loc", "label": "Lines", + "name": "Total lines", "remediation": "Split by responsibility into smaller units, extract helpers, and separate data definitions from behavior. For an average breach, break up the largest units first (--top).", "value_type": "int" }, @@ -382,7 +385,7 @@ "direction": "lower_better", "group": "complexity", "label": "Spaces", - "name": "Unit count", + "name": "Code units", "short": "Spaces", "value_type": "int" }, @@ -402,7 +405,7 @@ "formula": "effort ÷ 18", "group": "halstead", "label": "Time", - "name": "Halstead time, s", + "name": "Coding time (s)", "short": "H.time(s)", "value_type": "float" }, @@ -417,7 +420,7 @@ "formula": "eta1 + eta2", "group": "halstead", "label": "Vocabulary", - "name": "Halstead vocabulary", + "name": "Distinct symbols", "short": "H.vocab", "value_type": "float" }, @@ -428,7 +431,7 @@ "formula": "length × log₂(vocabulary)", "group": "halstead", "label": "Volume", - "name": "Halstead volume", + "name": "Code volume", "short": "H.vol", "value_type": "float" } diff --git a/docs/code-ranker-cli/CLI.md b/docs/code-ranker-cli/CLI.md index 6faeee83..cbdd82c2 100644 --- a/docs/code-ranker-cli/CLI.md +++ b/docs/code-ranker-cli/CLI.md @@ -594,10 +594,15 @@ e.g. `code-ranker docs HK` or `code-ranker docs ai`. code-ranker docs <subject> [--plugin <name|auto>] [--config <PATH|KEY=VALUE>] ``` -`code-ranker docs <subject>` prints a reference doc to stdout, then exits `0`. It **never -analyzes** and takes **no `[input]` positional** — config is auto-discovered from the -current directory, and `--plugin` (explicit `--plugin` > the `plugin` config key > none) -resolves which language's docs to serve. An unknown subject exits non-zero. +`code-ranker docs <subject>` prints a reference doc to stdout. It **never analyzes** and +takes **no `[input]` positional** — config is auto-discovered from the current directory, +and `--plugin` (explicit `--plugin` > the `plugin` config key > auto-detect from cwd +markers) resolves which language's docs to serve. A reference doc is **strictly +per-language**, so every subject but `ai` **requires a resolved plugin**: with none (no +marker, or ambiguous markers) the command fails with the same diagnostic `check` / +`report` give. An unknown subject exits non-zero. Subject matching is +**separator/case-insensitive** — `fan_in`, `Fan-in`, and `FAN in` all resolve the same +metric. `<subject>` selects what to print: @@ -607,7 +612,7 @@ resolves which language's docs to serve. An unknown subject exits non-zero. | `metrics` | An **index of every metric**, grouped by category. | | `principles` | An **index of every design principle**. | | a metric **category** (`loc`, `complexity`, `halstead`, `maintainability`, `coupling`) | The category's label/description **plus** its member metrics. | -| a **metric** key (`sloc`, `hk`, …) | The metric's **spec card** (label / name / description / category / formula). For metrics with a full prose doc (`hk`, `cyclomatic`, `cognitive`, `fan_in`, `fan_out`) the prose doc is appended after the card. | +| a **metric** key (`sloc`, `hk`, the language's own `unsafe` / `items`, …) | The metric's **spec card** (label / name / description / category / formula). For metrics with a full prose doc (`hk`, `cyclomatic`, `cognitive`, `fan_in`, `fan_out`) the prose doc is appended after the card. | | a **principle** id (`SRP`, `ADP`, … including project-defined `[principles.<ID>]`) | The principle's **full doc** (or a synthetic card for a doc-less custom principle). | | *(none, or an unknown subject)* | A **catalog of every subject**. No subject exits `0`; an unknown subject exits non-zero. | @@ -619,12 +624,13 @@ brief product intro **plus** a *Select a language* section (how to choose one wi the catalog until a language is chosen. ```sh -code-ranker docs # the catalog of every subject +code-ranker docs # the catalog of every subject (needs a resolved plugin) code-ranker docs ai # auto-detect: full playbook, or how to pick a plugin code-ranker docs ai --plugin rust # force a language → the full playbook + catalog -code-ranker docs HK # the full HK principle text, to stdout +code-ranker docs hk # the HK metric card + its full doc, to stdout code-ranker docs metrics # the metric index, grouped by category code-ranker docs coupling # the coupling category + its member metrics +code-ranker docs unsafe # a language-specific metric (rust) code-ranker docs cycle # the ADP doc (cycle is ADP's metric lens) ``` diff --git a/docs/code-ranker-cli/DESIGN.md b/docs/code-ranker-cli/DESIGN.md index 0cc1e4b9..87b1f006 100644 --- a/docs/code-ranker-cli/DESIGN.md +++ b/docs/code-ranker-cli/DESIGN.md @@ -37,12 +37,15 @@ a bare invocation prints help. `main()` owns two analysis subcommands — `check and `report` — both taking a single polymorphic positional `[input]` (a directory to **analyze**, or a `.json`/`.html` snapshot to **read**, via `analyze_input` → `is_snapshot_input`); a third `docs` subcommand (`docs.rs`) runs **no -analysis** and takes **no `[input]`** — it resolves the language plugin -(`plugin::resolve_plugin`) only to pick the language, then prints the reference doc for -the requested `<subject>` to stdout (the `ai` playbook, a metric/principle index, a -category or metric spec card, or a principle's full doc; for `docs ai`, the full -playbook + catalog when a plugin resolves, a brief intro + how to select one when -none does): +analysis** and takes **no `[input]`** — a reference doc is **strictly per-language**, so +it resolves the language plugin (`plugin::resolve_plugin`) and, for every subject but +`ai`, **fails** when none resolves (the same diagnostic `check` / `report` give). It +then builds the principle + metric + category specs from the plugin's own level specs +(`plugin::levels`, so a language metric like Rust's `unsafe` surfaces) layered with the +central catalog — no graph — and prints the reference doc for the requested `<subject>` +to stdout (the `ai` playbook, a metric/principle index, a category or metric spec card, +or a principle's full doc; for `docs ai`, the full playbook + catalog when a plugin +resolves, a brief intro + how to select one when none does): The binary is decomposed by concern — `main()` only parses and dispatches: `cli.rs` (the clap argument model), `analyze.rs` (input dispatch, the snapshot diff --git a/docs/code-ranker-cli/PRD.md b/docs/code-ranker-cli/PRD.md index 385d3263..31f579f9 100644 --- a/docs/code-ranker-cli/PRD.md +++ b/docs/code-ranker-cli/PRD.md @@ -57,20 +57,24 @@ snapshot input. always exits `0`. Without `--baseline` the HTML is a single-snapshot viewer; with `--baseline <snapshot>` it becomes a baseline↔current diff view with a verdict, named `…-diff.html`. -- `docs <subject>` prints a reference doc to stdout and always exits `0` (an unknown - subject exits non-zero). It runs **no analysis** and takes **no `[input]`** — config - is auto-discovered from the current directory, and `--plugin` (explicit, the `plugin` - config key, or none) resolves which language's docs to serve. The `<subject>` selects - the output: `ai` (the offline AI-agent playbook from the embedded `base/AI.md`), - `metrics` / `principles` (the metric / principle index), a metric **category** (`loc`, - `complexity`, `halstead`, `maintainability`, `coupling` → its label + member metrics), - a **metric** key (`sloc`, `hk`, … → its spec card, with the prose doc appended for - `hk` / `cyclomatic` / `cognitive` / `fan_in` / `fan_out`), a **principle** id (`SRP`, - `ADP`, … → its full doc), or no/unknown subject (a catalog of every subject). For - `docs ai`, with a plugin resolved it prints the full playbook **plus** the - principle/metric catalog; with none resolvable it prints a brief product intro - **plus** how to select a plugin and **omits** the catalog. So `docs ai` succeeds even - where `report` / `check` would stop on an ambiguous project, and guides the user to a +- `docs <subject>` prints a reference doc to stdout (an unknown subject exits + non-zero). It runs **no analysis** and takes **no `[input]`** — config is + auto-discovered from the current directory, and `--plugin` (explicit > the `plugin` + config key > auto-detect from cwd markers) resolves which language's docs to serve. + A reference doc is **strictly per-language**, so every subject but `ai` **requires a + resolved plugin**: with none (no marker, or ambiguous markers) the command fails with + the same diagnostic `check` / `report` give — name one with `--plugin` or set `plugin` + in `code-ranker.toml`. The `<subject>` selects the output: `metrics` / `principles` + (the metric / principle index), a metric **category** (`loc`, `complexity`, + `halstead`, `maintainability`, `coupling` → its label + member metrics), a **metric** + key (`sloc`, `hk`, the language's own `unsafe`/`items`, … → its spec card, with the + prose doc appended where one exists), a **principle** id (`SRP`, `ADP`, … → its full + doc), or no/unknown subject (a catalog of every subject). Subject matching is + separator/case-insensitive (`fan_in` = `Fan-in` = `FAN in`). The one exception is + **`docs ai`** (the offline AI-agent playbook from the embedded `base/AI.md`): with a + plugin resolved it prints the full playbook **plus** the principle/metric catalog; + with none resolvable it prints a brief product intro **plus** how to select a plugin + and **omits** the catalog — so `docs ai` always succeeds and guides the user to a working setup. `report` selects artifacts and their destinations through one flag family, From 903b9c5e05f8a69acb5fa229886b144f97bec113 Mon Sep 17 00:00:00 2001 From: Roman Fedorov <Roman.Fedorov@constructor.tech> Date: Fri, 26 Jun 2026 00:08:39 +0300 Subject: [PATCH 40/40] test: raise coverage on docs/main/log/plugin/pipeline Cover the patch lines codecov/patch flagged on this branch: - docs.rs: build_specs (project metrics/principles + neutral input), categories_block edge branches, render_catalog(None) - plugin/mod.rs: unknown-plugin fallback arms of the registry accessors - log.rs: level round-trip + verbose/subcmd emission at VERBOSE - e2e: bare docs, docs principles, unknown subject, --export-full-config, --output.mode quiet/verbose, and plugin-resolution failure in the pipeline main.rs and log.rs reach 100%; diff-coverage vs origin/main is clean. --- crates/code-ranker-cli/src/docs_test.rs | 123 ++++++++++++++++ crates/code-ranker-cli/src/plugin/mod_test.rs | 40 +++++ crates/code-ranker-cli/tests/e2e.rs | 138 ++++++++++++++++++ crates/code-ranker-plugin-api/src/log.rs | 34 +++++ 4 files changed, 335 insertions(+) diff --git a/crates/code-ranker-cli/src/docs_test.rs b/crates/code-ranker-cli/src/docs_test.rs index e78d92de..3f7a8fc6 100644 --- a/crates/code-ranker-cli/src/docs_test.rs +++ b/crates/code-ranker-cli/src/docs_test.rs @@ -172,3 +172,126 @@ fn principles_block_reports_when_the_plugin_defines_none() { let out = render_principles_index(&s); assert!(out.contains("(none"), "empty principles note: {out}"); } + +#[test] +fn catalog_without_unknown_omits_the_lead_note() { + // The bare-`docs` path passes `None` — the catalog is the help, so no lead note. + let out = render_catalog(&specs(), None); + assert!( + !out.contains("Unknown docs subject"), + "no unknown-subject note for the help view: {out}" + ); + assert!( + out.contains("code-ranker docs <subject>"), + "still prints the catalog header: {out}" + ); +} + +#[test] +fn categories_block_falls_back_to_the_label_for_a_group_without_a_description() { + // A metric naming a category that ships no `[categories.<key>]` label/description: + // the category key is still listed (header falls back to its Titlecase label). + let mut s = specs(); + s.node_attributes.insert( + "depth".to_string(), + metric("Depth", "Nesting depth", "Max nesting.", "complexity"), + ); + // No `groups["complexity"]` entry → the `None` description branch. + let out = categories_block(&s); + // No group entry → `category_label` falls back to the key itself. + assert!( + out.contains("complexity — complexity"), + "category with no description echoes its key as the label: {out}" + ); + assert!( + out.contains("- depth: Nesting depth"), + "member listed: {out}" + ); +} + +#[test] +fn categories_block_lists_uncategorized_metrics_with_a_description() { + let mut s = specs(); + // group = None + a description → surfaces under the (uncategorized) heading. + let mut cycle = AttributeSpec::new(ValueType::Str, "Cycle"); + cycle.name = Some("Cycle member".to_string()); + cycle.description = Some("Part of a dependency cycle.".to_string()); + s.node_attributes.insert("cycle".to_string(), cycle); + // group = None + NO description (bare external metadata) → skipped entirely. + s.node_attributes.insert( + "crate".to_string(), + AttributeSpec::new(ValueType::Str, "Crate"), + ); + let out = categories_block(&s); + assert!( + out.contains("(uncategorized)"), + "uncategorized heading: {out}" + ); + assert!( + out.contains("- cycle: Cycle member"), + "described uncategorized metric listed: {out}" + ); + assert!( + !out.contains("- crate:"), + "doc-less metadata is skipped: {out}" + ); +} + +#[test] +fn build_specs_without_config_uses_the_plugin_catalog_and_neutral_input() { + // No config: exercises `default_plugin_input` and the `None` principle branch — + // the result is the plugin's own catalog + central metric specs, undecorated. + let specs = build_specs("rust", None); + assert!( + specs.node_attributes.contains_key("sloc"), + "central LOC metric present" + ); + assert!( + specs.principles.iter().any(|p| p.id == "ADP"), + "rust's principle catalog is present" + ); +} + +#[test] +fn build_specs_overlays_project_metrics_and_principles() { + let mut cfg = config::model::Config::default(); + // A node-scope `[metrics.<key>]` becomes a first-class metric subject. + let mut def = code_ranker_graph::MetricDef { + formula_cel: "sloc * 2".to_string(), + ..Default::default() + }; + def.scope = code_ranker_graph::Scope::Node; + def.name = Some("Doubled SLOC".to_string()); + def.description = Some("Twice the source lines.".to_string()); + cfg.metrics.insert("dbl".to_string(), def); + // A graph-scope metric must NOT leak into the node-attribute dictionary. + let mut agg = code_ranker_graph::MetricDef { + formula_cel: "sum(sloc)".to_string(), + ..Default::default() + }; + agg.scope = code_ranker_graph::Scope::Graph; + cfg.metrics.insert("total".to_string(), agg); + // A `[principles.<ID>]` is appended to the catalog. + cfg.principles.insert( + "TSR".to_string(), + config::model::PrincipleDef { + sort_metric: "dbl".to_string(), + title: Some("TSR — Test Ratio".to_string()), + ..Default::default() + }, + ); + + let specs = build_specs("rust", Some(cfg)); + assert!( + specs.node_attributes.contains_key("dbl"), + "node-scope project metric surfaced" + ); + assert!( + !specs.node_attributes.contains_key("total"), + "graph-scope metric stays out of the node dictionary" + ); + assert!( + specs.principles.iter().any(|p| p.id == "TSR"), + "project principle merged into the catalog" + ); +} diff --git a/crates/code-ranker-cli/src/plugin/mod_test.rs b/crates/code-ranker-cli/src/plugin/mod_test.rs index 19ca09b0..def395d0 100644 --- a/crates/code-ranker-cli/src/plugin/mod_test.rs +++ b/crates/code-ranker-cli/src/plugin/mod_test.rs @@ -120,6 +120,46 @@ fn levels_returns_the_spec_for_a_known_plugin_and_empty_for_unknown() { assert!(levels("nope").is_empty(), "unknown plugin → no levels"); } +#[test] +fn unknown_plugin_accessors_degrade_gracefully() { + // Every registry accessor takes a plugin *name*; an unknown one must return the + // documented empty/zero fallback (the `None` arm) rather than panic. + let tmp = tempfile::tempdir().unwrap(); + let input = PluginInput::default(); + let mut graph = Graph::default(); + + assert!( + analyze("nope", tmp.path(), &input).is_err(), + "analyze with an unknown plugin errors" + ); + assert_eq!( + annotate_metrics("nope", &mut graph), + 0, + "no metrics annotated for an unknown plugin" + ); + assert!( + function_units("nope", &graph).is_empty(), + "no function units for an unknown plugin" + ); + assert!( + principles("nope", &input).is_empty(), + "no principles for an unknown plugin" + ); + // `metric_specs` returns the defaults verbatim for an unknown plugin. + let defaults: BTreeMap<String, AttributeSpec> = [( + "sloc".to_string(), + AttributeSpec::new(code_ranker_plugin_api::attrs::ValueType::Int, "Source"), + )] + .into_iter() + .collect(); + let out = metric_specs("nope", defaults.clone()); + assert!( + out.contains_key("sloc"), + "defaults passed through unchanged" + ); + assert_eq!(out.len(), defaults.len(), "no plugin refinement applied"); +} + #[test] fn resolve_plugin_failure_points_at_config() { // No marker resolves here, so the error guides the user to pin the language — diff --git a/crates/code-ranker-cli/tests/e2e.rs b/crates/code-ranker-cli/tests/e2e.rs index fd0cf1ea..351b0cd1 100644 --- a/crates/code-ranker-cli/tests/e2e.rs +++ b/crates/code-ranker-cli/tests/e2e.rs @@ -1527,3 +1527,141 @@ fn empty_metric_warns_on_stderr() { "project-wide-empty warning expected on stderr, got: {stderr}" ); } + +/// Bare `docs` (no subject) prints the subject catalog and exits `0` — the catalog +/// *is* the help. Run from the Rust sample so a plugin auto-resolves (every subject +/// but `ai` is strictly per-language). +#[test] +fn docs_bare_prints_the_catalog_and_exits_zero() { + let res = Command::new(env!("CARGO_BIN_EXE_code-ranker")) + .current_dir(sample_dir("rust")) + .arg("docs") + .output() + .expect("spawn docs"); + assert!( + res.status.success(), + "bare docs must exit 0: {}", + String::from_utf8_lossy(&res.stderr) + ); + let stdout = String::from_utf8_lossy(&res.stdout); + assert!( + stdout.contains("code-ranker docs <subject>"), + "catalog header present: {stdout}" + ); + assert!( + stdout.contains("principles") && stdout.contains("ADP"), + "principles group + a member listed: {stdout}" + ); +} + +/// `docs principles` prints the principle index (the `principles` subject branch). +#[test] +fn docs_principles_index_lists_every_principle() { + let res = Command::new(env!("CARGO_BIN_EXE_code-ranker")) + .current_dir(sample_dir("rust")) + .args(["docs", "principles"]) + .output() + .expect("spawn docs principles"); + assert!(res.status.success(), "docs principles failed"); + let stdout = String::from_utf8_lossy(&res.stdout); + assert!( + stdout.contains("Principles — print one with"), + "index header: {stdout}" + ); + assert!(stdout.contains("ADP"), "a principle id listed: {stdout}"); +} + +/// An unknown `docs` subject prints the catalog (so the caller sees every option) +/// and exits **non-zero** — it was a real lookup miss, not a help request. +#[test] +fn docs_unknown_subject_prints_catalog_and_fails() { + let res = Command::new(env!("CARGO_BIN_EXE_code-ranker")) + .current_dir(sample_dir("rust")) + .args(["docs", "no-such-subject"]) + .output() + .expect("spawn docs"); + assert!(!res.status.success(), "unknown subject must exit non-zero"); + let stdout = String::from_utf8_lossy(&res.stdout); + assert!( + stdout.contains("Unknown docs subject"), + "lead note names the miss: {stdout}" + ); +} + +/// `report --export-full-config PATH` writes the merged `[project]` + `[plugin]` +/// config and runs no analysis (the `Some(path)` arm in `main`). +#[test] +fn report_export_full_config_writes_both_sections() { + let sample = sample_dir("rust"); + let dir = tempfile::tempdir().expect("temp dir"); + let out = dir.path().join("full.toml"); + let res = Command::new(env!("CARGO_BIN_EXE_code-ranker")) + .current_dir(dir.path()) + .arg("report") + .arg(&sample) + .arg("--config") + .arg(sample.join("code-ranker.toml")) + .arg("--export-full-config") + .arg(&out) + .output() + .expect("spawn export-full-config"); + assert!( + res.status.success(), + "export-full-config failed: {}", + String::from_utf8_lossy(&res.stderr) + ); + let body = std::fs::read_to_string(&out).expect("config dump written"); + assert!( + body.contains("[project]") && body.contains("[plugin]"), + "both sections present: {body}" + ); +} + +/// `--output.mode quiet` silences the closing `✓` line on a successful run; the +/// global flag is accepted after the subcommand. (Exercises the `Quiet` arm.) +#[test] +fn output_mode_quiet_suppresses_the_finish_line() { + let (ok, _stdout, stderr) = run_report_capture("rust", &["--output.mode", "quiet"]); + assert!(ok, "quiet report should still succeed: {stderr}"); + assert!( + !stderr.contains('✓'), + "quiet suppresses the closing ✓ line: {stderr}" + ); +} + +/// `--output.mode verbose` adds the `▶` startup line and per-tool `↳` timings to +/// stderr. (Exercises the `Verbose` arm.) +#[test] +fn output_mode_verbose_emits_the_startup_line() { + let (ok, _stdout, stderr) = run_report_capture("rust", &["--output.mode", "verbose"]); + assert!(ok, "verbose report should succeed: {stderr}"); + assert!( + stderr.contains('▶'), + "verbose prints the ▶ startup line: {stderr}" + ); +} + +/// `report` on a directory with no plugin marker (and no `--plugin`) fails at +/// plugin resolution inside the pipeline — the `?` on `resolve_plugin`. The error +/// guides the user to pin a language (the same diagnostic `check` gives). +#[test] +fn report_fails_when_no_plugin_resolves() { + let dir = tempfile::tempdir().expect("temp dir"); + // An empty directory: config loads (defaults), but no project marker matches, + // so plugin auto-detection fails before any analysis runs. + let res = Command::new(env!("CARGO_BIN_EXE_code-ranker")) + .current_dir(dir.path()) + .arg("report") + .arg(".") + .output() + .expect("spawn report"); + assert!( + !res.status.success(), + "report must fail when no plugin resolves" + ); + let stderr = String::from_utf8_lossy(&res.stderr); + assert!( + stderr.contains("auto-detect") || stderr.contains("--plugin"), + "error points at pinning a language: {stderr}" + ); +} diff --git a/crates/code-ranker-plugin-api/src/log.rs b/crates/code-ranker-plugin-api/src/log.rs index 6a56ad18..7ed4d90a 100644 --- a/crates/code-ranker-plugin-api/src/log.rs +++ b/crates/code-ranker-plugin-api/src/log.rs @@ -93,3 +93,37 @@ pub fn timed<T>(label: &str, f: impl FnOnce() -> T) -> T { subcmd(label, start.elapsed()); out } + +#[cfg(test)] +mod tests { + use super::*; + + /// The level switch round-trips, and `secs` renders millisecond precision. + #[test] + fn level_set_and_get_round_trips() { + let saved = level(); + set_level(QUIET); + assert_eq!(level(), QUIET); + set_level(VERBOSE); + assert_eq!(level(), VERBOSE); + set_level(saved); + assert_eq!(secs(Duration::from_millis(231)), "0.231s"); + } + + /// `timed` runs `f` and returns its value at EVERY level — the work is never + /// gated, only the line. Exercising it at VERBOSE drives the gated emission in + /// `subcmd` (and, via `verbose`, the matching `line` call) without asserting on + /// the stderr text. The level is saved and restored so the process-wide switch + /// is left as found for sibling tests. + #[test] + fn timed_runs_and_logs_at_verbose() { + let saved = level(); + set_level(VERBOSE); + let out = timed("unit-test sub-command", || { + verbose("a verbose-only diagnostic line"); + 7 + }); + set_level(saved); + assert_eq!(out, 7, "timed returns the closure's value"); + } +}