trusty-analyze 0.4.1

Sidecar code-analysis daemon for trusty-search: complexity, smells, quality, facts
Documentation
<script>
  /*
   * Why: Operators need to see which smell categories dominate a corpus and
   * drill into specific offenders. A bar chart on category counts surfaces
   * the prevailing problem class at a glance.
   * What: D3 bar chart of smell-category counts + a filterable detail list.
   * Test: Select an index, expect bars for each category; click a category
   * and the list filters to that smell name.
   */
  import * as d3 from 'd3';
  import { onMount, onDestroy, tick } from 'svelte';
  import {
    getSelectedIndex,
    getSmells,
    refreshSmells,
    getTheme
  } from '../state.svelte.js';

  let selected = $derived(getSelectedIndex());
  let smells = $derived(getSmells());
  let category = $state('');
  let chartEl = $state(null);

  $effect(() => {
    if (!selected) return;
    refreshSmells(selected, category || undefined).catch(() => {});
  });

  /*
   * Why: API may return one row per chunk with a `smells: [...]` array, or
   * pre-flattened rows; we normalize to a flat list of {category, chunk}.
   * What: Returns Array<{ category, chunk }>.
   * Test: Pass [{ smells: [{ category: 'long_function' }], file: 'a.rs' }]
   * and expect [{ category: 'long_function', chunk: {...} }].
   */
  function flatten(rows) {
    const out = [];
    for (const r of rows || []) {
      if (Array.isArray(r.smells) && r.smells.length) {
        for (const s of r.smells) {
          out.push({ category: s.category || s.name || 'unknown', chunk: r, smell: s });
        }
      } else if (r.category || r.name) {
        out.push({ category: r.category || r.name, chunk: r, smell: r });
      }
    }
    return out;
  }

  let flat = $derived(flatten(smells));
  let counts = $derived.by(() => {
    const m = new Map();
    for (const f of flat) m.set(f.category, (m.get(f.category) || 0) + 1);
    return [...m.entries()].map(([category, count]) => ({ category, count }))
      .sort((a, b) => b.count - a.count);
  });
  let filtered = $derived(category ? flat.filter((f) => f.category === category) : flat);

  function renderBars() {
    if (!chartEl) return;
    d3.select(chartEl).selectAll('*').remove();
    const data = counts;
    if (!data.length) return;
    const cs = getComputedStyle(document.documentElement);
    const color = {
      subtext: cs.getPropertyValue('--subtext').trim(),
      border: cs.getPropertyValue('--border').trim(),
      text: cs.getPropertyValue('--text').trim(),
      mauve: cs.getPropertyValue('--mauve').trim()
    };

    const width = chartEl.clientWidth || 800;
    const height = 280;
    const margin = { top: 16, right: 16, bottom: 60, left: 48 };
    const innerW = width - margin.left - margin.right;
    const innerH = height - margin.top - margin.bottom;

    const x = d3.scaleBand().domain(data.map((d) => d.category)).range([0, innerW]).padding(0.2);
    const y = d3.scaleLinear().domain([0, d3.max(data, (d) => d.count) || 1]).nice().range([innerH, 0]);

    const svg = d3
      .select(chartEl)
      .append('svg')
      .attr('width', width)
      .attr('height', height)
      .attr('viewBox', `0 0 ${width} ${height}`);

    const g = svg.append('g').attr('transform', `translate(${margin.left},${margin.top})`);

    g.append('g')
      .attr('transform', `translate(0,${innerH})`)
      .call(d3.axisBottom(x))
      .selectAll('text')
      .style('fill', color.subtext)
      .style('font-size', '11px')
      .attr('transform', 'rotate(-25)')
      .attr('text-anchor', 'end')
      .attr('dx', '-0.4em')
      .attr('dy', '0.6em');

    g.append('g')
      .call(d3.axisLeft(y).ticks(5))
      .selectAll('text')
      .style('fill', color.subtext)
      .style('font-size', '11px');

    g.selectAll('.domain, .tick line').style('stroke', color.border);

    g.selectAll('rect.bar')
      .data(data)
      .join('rect')
      .attr('class', 'bar')
      .attr('x', (d) => x(d.category))
      .attr('y', (d) => y(d.count))
      .attr('width', x.bandwidth())
      .attr('height', (d) => innerH - y(d.count))
      .attr('fill', color.mauve)
      .attr('fill-opacity', 0.85)
      .style('cursor', 'pointer')
      .on('click', (_e, d) => {
        category = category === d.category ? '' : d.category;
      });

    g.selectAll('text.value')
      .data(data)
      .join('text')
      .attr('class', 'value')
      .attr('x', (d) => x(d.category) + x.bandwidth() / 2)
      .attr('y', (d) => y(d.count) - 4)
      .attr('text-anchor', 'middle')
      .style('fill', color.text)
      .style('font-size', '11px')
      .style('font-weight', '600')
      .text((d) => d.count);
  }

  let ro;
  onMount(async () => {
    await tick();
    renderBars();
    ro = new ResizeObserver(() => renderBars());
    if (chartEl) ro.observe(chartEl);
  });
  onDestroy(() => ro && ro.disconnect());

  $effect(() => {
    counts;
    getTheme();
    if (chartEl) renderBars();
  });
</script>

<h1 class="page-title">Smells</h1>

{#if !selected}
  <div class="card"><div class="empty">Select an index in the top bar.</div></div>
{:else}
  <div class="card mb-4">
    <div class="card-header flex-between">
      <span>By Category</span>
      {#if category}
        <button class="btn btn-sm" onclick={() => (category = '')}>clear filter: {category}</button>
      {/if}
    </div>
    <div class="card-body">
      <div bind:this={chartEl} class="bar-chart"></div>
      {#if counts.length === 0}
        <div class="empty">No smells detected for this index.</div>
      {/if}
    </div>
  </div>

  <div class="card">
    <div class="card-header">
      Detail {filtered.length ? `(${filtered.length})` : ''}
    </div>
    <div class="card-body" style="padding: 0">
      {#if filtered.length === 0}
        <div class="empty">No matching smells.</div>
      {:else}
        <table class="table">
          <thead>
            <tr>
              <th>Category</th>
              <th>Function</th>
              <th>File</th>
              <th>Lines</th>
              <th>Severity</th>
            </tr>
          </thead>
          <tbody>
            {#each filtered.slice(0, 200) as f}
              {@const c = f.chunk || {}}
              {@const sev = (f.smell?.severity || '').toString()}
              <tr>
                <td><span class="badge">{f.category}</span></td>
                <td class="text-mono text-xs">{c.function_name || '—'}</td>
                <td class="text-muted text-xs truncate" style="max-width: 320px">{c.file || '—'}</td>
                <td class="text-xs text-muted">{c.start_line ?? '?'}–{c.end_line ?? '?'}</td>
                <td>
                  {#if sev}
                    <span class="badge sev-{sev.toLowerCase()}">{sev}</span>
                  {:else}
                    <span class="text-muted text-xs">—</span>
                  {/if}
                </td>
              </tr>
            {/each}
          </tbody>
        </table>
      {/if}
    </div>
  </div>
{/if}

<style>
  .page-title {
    font-size: var(--trusty-fs-xl);
    margin: 0 0 var(--trusty-space-5) 0;
    font-weight: 600;
  }
  .bar-chart {
    width: 100%;
    min-height: 280px;
  }
</style>