feat(coverage): pattern-library coverage measurement (#2692, milestone 288 step 7)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 58s
CI & Build / Build & push image (push) Successful in 41s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 58s
CI & Build / Build & push image (push) Successful in 41s
Server-side shape enumeration per bound repo — one archive download via the forge adapter, definitions extracted with a Python mirror of the write-path hook's awk rules (shared test vectors pin the two together) — compared against recorded snippet locations by path+symbol. Summary is cached in the settings KV with a freshness stamp; recomputed on webhook push (spawned off the delivery path) or explicit refresh, never in a request path. Surfaces: GET/POST /api/projects/<id>/coverage[/refresh], a project-page card (estimate-labeled, largest-gaps chips), and a one-line evidence-carrying entry in enter_project read from cache only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -418,11 +418,66 @@ async function loadNotes() {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Pattern-library coverage (#2692) ─────────────────────────── */
|
||||
|
||||
interface CoverageGap {
|
||||
dir: string;
|
||||
uncovered: number;
|
||||
total: number;
|
||||
}
|
||||
interface Coverage {
|
||||
total: number;
|
||||
recorded: number;
|
||||
estimate: boolean;
|
||||
computed_at: string;
|
||||
repos: { repo: string; ref: string; total: number; recorded: number }[];
|
||||
largest_gaps: CoverageGap[];
|
||||
}
|
||||
|
||||
const coverage = ref<Coverage | null>(null);
|
||||
const coverageConfigured = ref(false);
|
||||
const coverageRefreshing = ref(false);
|
||||
const coverageError = ref<string | null>(null);
|
||||
|
||||
/** Swallows failure like loadDesignSystems: no forge is the ordinary state
|
||||
* for most installs, and this card must never break the project page. */
|
||||
async function loadCoverage() {
|
||||
try {
|
||||
const res = await apiGet<{ configured: boolean; coverage: Coverage | null }>(
|
||||
`/api/projects/${projectId.value}/coverage`
|
||||
);
|
||||
coverageConfigured.value = res.configured;
|
||||
coverage.value = res.coverage;
|
||||
} catch {
|
||||
coverageConfigured.value = false;
|
||||
coverage.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshCoverage() {
|
||||
if (coverageRefreshing.value) return;
|
||||
coverageRefreshing.value = true;
|
||||
coverageError.value = null;
|
||||
try {
|
||||
const res = await apiPost<{ coverage: Coverage }>(
|
||||
`/api/projects/${projectId.value}/coverage/refresh`,
|
||||
{}
|
||||
);
|
||||
coverage.value = res.coverage;
|
||||
} catch (e) {
|
||||
coverageError.value =
|
||||
e instanceof Error ? e.message : "Coverage refresh failed";
|
||||
} finally {
|
||||
coverageRefreshing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadProject();
|
||||
loadTasks();
|
||||
loadNotes();
|
||||
loadDesignSystems();
|
||||
loadCoverage();
|
||||
});
|
||||
|
||||
/** Populate the design-system picker. Swallows failure on purpose: with no
|
||||
@@ -440,6 +495,7 @@ watch(projectId, async () => {
|
||||
await loadProject();
|
||||
loadTasks();
|
||||
loadNotes();
|
||||
loadCoverage();
|
||||
});
|
||||
|
||||
watch(
|
||||
@@ -592,6 +648,53 @@ async function confirmDelete() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pattern-library coverage (#2692) — only when a forge is configured;
|
||||
forge-less installs never see this card at all. -->
|
||||
<div v-if="coverageConfigured" class="coverage-card">
|
||||
<div class="coverage-head">
|
||||
<span class="coverage-title">Pattern coverage</span>
|
||||
<span class="coverage-estimate" title="Keyword-based extraction over- and under-counts; watch the trend, not the digit">estimate</span>
|
||||
<span v-if="coverage" class="coverage-when">computed {{ relativeTime(coverage.computed_at) }}</span>
|
||||
<button
|
||||
class="btn-ghost btn-compact coverage-refresh"
|
||||
:disabled="coverageRefreshing"
|
||||
@click="refreshCoverage"
|
||||
>
|
||||
{{ coverageRefreshing ? "Measuring…" : "Refresh" }}
|
||||
</button>
|
||||
</div>
|
||||
<template v-if="coverage">
|
||||
<div class="coverage-numbers">
|
||||
<span class="coverage-count">{{ coverage.recorded }}/{{ coverage.total }}</span>
|
||||
<span class="coverage-label">shapes recorded</span>
|
||||
</div>
|
||||
<div
|
||||
class="coverage-bar"
|
||||
role="progressbar"
|
||||
:aria-valuenow="coverage.recorded"
|
||||
:aria-valuemin="0"
|
||||
:aria-valuemax="coverage.total"
|
||||
aria-label="Shapes with a recorded snippet"
|
||||
>
|
||||
<div
|
||||
class="coverage-bar-fill"
|
||||
:style="{ width: (coverage.total ? (coverage.recorded / coverage.total) * 100 : 0) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<div v-if="coverage.largest_gaps?.length" class="coverage-gaps">
|
||||
<span class="coverage-gaps-label">Largest gaps:</span>
|
||||
<span v-for="gap in coverage.largest_gaps" :key="gap.dir" class="coverage-gap-chip">
|
||||
{{ gap.dir }} <span class="coverage-gap-count">{{ gap.uncovered }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="coverage-empty">
|
||||
Not measured yet — Refresh compares the bound repo's definitions
|
||||
against recorded snippets.
|
||||
</p>
|
||||
<p v-if="coverageError" class="coverage-error">{{ coverageError }}</p>
|
||||
</div>
|
||||
|
||||
<div class="project-body">
|
||||
|
||||
<!-- Edit panel -->
|
||||
@@ -1037,6 +1140,89 @@ async function confirmDelete() {
|
||||
.stat-done { background: color-mix(in srgb, var(--fs-success) 10%, transparent); color: var(--fs-success); border-color: color-mix(in srgb, var(--fs-success) 28%, transparent); }
|
||||
.stat-notes { background: color-mix(in srgb, var(--fs-accent) 8%, transparent); color: var(--fs-accent); border-color: color-mix(in srgb, var(--fs-accent) 22%, transparent); }
|
||||
|
||||
/* ── Pattern-library coverage card ───────────────────────────── */
|
||||
.coverage-card {
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.coverage-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.coverage-title {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.coverage-estimate {
|
||||
font-size: 0.68rem;
|
||||
padding: 0.05rem 0.4rem;
|
||||
border-radius: var(--fs-radius-lg);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
color: var(--fs-text-tertiary);
|
||||
cursor: help;
|
||||
}
|
||||
.coverage-when {
|
||||
font-size: 0.72rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.coverage-refresh { margin-left: auto; }
|
||||
.coverage-numbers {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.coverage-count { font-size: 1.15rem; font-weight: 500; }
|
||||
.coverage-label { font-size: 0.82rem; color: var(--fs-text-secondary); }
|
||||
.coverage-bar {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--fs-text-tertiary) 14%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
.coverage-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: var(--fs-accent);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.coverage-gaps {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.coverage-gaps-label { color: var(--fs-text-tertiary); }
|
||||
.coverage-gap-chip {
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: var(--fs-radius-lg);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
color: var(--fs-text-secondary);
|
||||
font-family: var(--fs-font-mono);
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
.coverage-gap-count { opacity: 0.65; }
|
||||
.coverage-empty {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.coverage-error {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--fs-error);
|
||||
}
|
||||
|
||||
/* ── Two-column body ─────────────────────────────────────────── */
|
||||
/* `minmax(0, 1fr)`, not `1fr`. A bare `1fr` track has an AUTO minimum, so it
|
||||
cannot shrink below its content — one wide descendant anywhere in the
|
||||
|
||||
Reference in New Issue
Block a user