diff --git a/platform/lib/queries/adoption-timeline.ts b/platform/lib/queries/adoption-timeline.ts index 6870281..cbf283c 100644 --- a/platform/lib/queries/adoption-timeline.ts +++ b/platform/lib/queries/adoption-timeline.ts @@ -9,6 +9,8 @@ * list of comparable rows (stabilization, durability, cascade, revert, new-code churn). */ +import { PP_STABLE } from "./temporal"; + import type { AdoptionConfidence, AdoptionTimeline, @@ -33,7 +35,7 @@ export interface AdoptionDelta { } export interface AdoptionSummary { - inflection: string; // YYYY-MM-DD + inflection: string; // YYYY-MM-DD rampEnd: string | null; confidence: AdoptionConfidence; totalAiCommits: number; @@ -46,9 +48,14 @@ export interface RepoAdoption extends AdoptionSummary { headlineDeltaPp: number | null; } -const FLAT_THRESHOLD_PP = 2; +// Was 2pp — well under the engine's own PP_STABLE floor (5pp) for "this +// delta isn't noise," so moves the engine's own trend narrative would call +// stable were rendering as a confident up/down arrow here. +const FLAT_THRESHOLD_PP = PP_STABLE; -function weightedDurabilitySurvival(m: ReportMetrics | undefined): number | null { +function weightedDurabilitySurvival( + m: ReportMetrics | undefined, +): number | null { if (!m?.durability_by_origin) return null; let surviving = 0; let introduced = 0; diff --git a/platform/lib/queries/dora.ts b/platform/lib/queries/dora.ts index 03788b2..fed230d 100644 --- a/platform/lib/queries/dora.ts +++ b/platform/lib/queries/dora.ts @@ -25,6 +25,16 @@ const PROVIDER = "datadog" as const; const COMMIT_CHUNK_SIZE = 100; const DEFAULT_WINDOW_DAYS = 30; +/** + * Below this many evaluated deploys, the headline KPI cards (CFR in + * particular — a ratio) are one-or-two-events noise dressed up as a + * precise percentage. Shared by the org dashboard's DORAOverview and the + * repo-detail page's DORARepoCard — a single repo is, if anything, MORE + * likely to sit below this floor than the org aggregate, so it needs the + * same guard, not a laxer one. + */ +export const MIN_EVALUATED_FOR_KPIS = 10; + const ORIGINS = ["HUMAN", "AI_ASSISTED", "BOT"] as const; type Origin = (typeof ORIGINS)[number]; diff --git a/platform/lib/queries/temporal.ts b/platform/lib/queries/temporal.ts index 482c7f5..2a4fec3 100644 --- a/platform/lib/queries/temporal.ts +++ b/platform/lib/queries/temporal.ts @@ -15,6 +15,18 @@ import { classifyHealth } from "@/types/temporal"; const SPARKLINE_POINTS = 12; +/** + * Canonical percentage-point significance thresholds, mirrored from + * `iris/analysis/trend_delta.py` (see docs/METRICS.md): delta < PP_STABLE is + * noise, PP_STABLE <= delta < PP_NOTABLE is worth mentioning, >= PP_NOTABLE + * is significant. Shared here so every platform-side "is this delta worth + * an alert" decision (detectChanges below, the adoption timeline's + * up/down/flat classification) uses the same floor as the engine's own + * narrative instead of independently hand-picked numbers. + */ +export const PP_STABLE = 5.0; +export const PP_NOTABLE = 15.0; + /** * Default lookback window in days. Mirrors the engine CLI's * `iris analyze --days` default. Every read from `metrics` filters by @@ -304,13 +316,14 @@ export function detectChanges( } } - // Stabilization drop > 10pp + // Stabilization drop >= PP_STABLE (the engine's own "no longer noise" + // floor — was 10pp, matching neither PP_STABLE nor PP_NOTABLE). check( "stabilization_ratio", `Stabilization ${current.stabilization_ratio !== null && previous.stabilization_ratio !== null && current.stabilization_ratio < previous.stabilization_ratio ? "dropped" : "improved"} by ${Math.abs(((current.stabilization_ratio ?? 0) - (previous.stabilization_ratio ?? 0)) * 100).toFixed(0)}pp`, current.stabilization_ratio, previous.stabilization_ratio, - 0.1, + PP_STABLE / 100, current.stabilization_ratio !== null && previous.stabilization_ratio !== null && current.stabilization_ratio < previous.stabilization_ratio @@ -318,13 +331,13 @@ export function detectChanges( : "info", ); - // Revert rate increase > 5pp + // Revert rate increase >= PP_STABLE (already matched this by coincidence). check( "revert_rate", `Revert rate changed by ${Math.abs(((current.revert_rate ?? 0) - (previous.revert_rate ?? 0)) * 100).toFixed(0)}pp`, current.revert_rate, previous.revert_rate, - 0.05, + PP_STABLE / 100, current.revert_rate !== null && previous.revert_rate !== null && current.revert_rate > previous.revert_rate @@ -332,13 +345,14 @@ export function detectChanges( : "info", ); - // AI coverage change > 15pp + // AI coverage change >= PP_STABLE (was 15pp, i.e. PP_NOTABLE — a much + // higher bar than the other two metrics here for no documented reason). check( "ai_detection_coverage_pct", `AI adoption changed by ${Math.abs((current.ai_detection_coverage_pct ?? 0) - (previous.ai_detection_coverage_pct ?? 0)).toFixed(0)}pp`, current.ai_detection_coverage_pct, previous.ai_detection_coverage_pct, - 15, + PP_STABLE, "info", ); diff --git a/platform/lib/translations.ts b/platform/lib/translations.ts index 972d5b2..772021f 100644 --- a/platform/lib/translations.ts +++ b/platform/lib/translations.ts @@ -460,6 +460,8 @@ export const translations = { "Systemic patterns that drive the most rework in this repository.", empty: "No systemic hotspots detected. Stabilization, coupling and fix distribution all look within healthy ranges.", + noData: + "Not enough data to evaluate yet — this repo has no stability map, coupling, or fix-targeting data in this window.", severityHigh: "High", severityMedium: "Medium", severityLow: "Low", @@ -593,6 +595,7 @@ export const translations = { revertRate: "Revert Rate", churnEvents: "Churn Events", commits: "Commits", + noActivityHint: "No commits in this window", }, mergeStrategy: { label: "Merge", @@ -604,7 +607,9 @@ export const translations = { dora: { title: "DORA", subtitle: - "Deployment metrics scoped to this repository over the last {days} days.", + "Deployment metrics scoped to this repository over the last {days} days — live from Datadog as of now. The rest of this page reflects the last analysis run, which may be older.", + lowSample: + "Based on only {actual} evaluated deploys (below the {threshold} this page treats as a stable read) — treat these four as directional, not precise.", incidentDisclaimer: "MTTR by incident isn't shown per-repo — Datadog failure events don't carry repository attribution, so any per-repo number would be a misleading copy of the org-wide one. See the dashboard for the incident-level view.", }, @@ -1862,6 +1867,8 @@ export const translations = { "Padrões sistêmicos que geram mais retrabalho neste repositório.", empty: "Nenhum hotspot sistêmico detectado. Estabilização, acoplamento e distribuição de fixes estão dentro de faixas saudáveis.", + noData: + "Ainda não há dados suficientes pra avaliar — esse repositório não tem stability map, acoplamento ou dados de fix-targeting nessa janela.", severityHigh: "Alto", severityMedium: "Médio", severityLow: "Baixo", @@ -1997,6 +2004,7 @@ export const translations = { revertRate: "Taxa de revert", churnEvents: "Eventos de churn", commits: "Commits", + noActivityHint: "Sem commits nessa janela", }, mergeStrategy: { label: "Merge", @@ -2008,7 +2016,9 @@ export const translations = { dora: { title: "DORA", subtitle: - "Métricas de deploy deste repositório nos últimos {days} dias.", + "Métricas de deploy deste repositório nos últimos {days} dias — direto do Datadog, a partir de agora. O resto desta página reflete a última análise, que pode ser mais antiga.", + lowSample: + "Baseado em apenas {actual} deploys avaliados (abaixo dos {threshold} que esta página considera uma leitura estável) — trate esses quatro números como direcionais, não precisos.", incidentDisclaimer: "MTTR por incidente não aparece per-repo — os eventos de falha do Datadog não carregam atribuição de repositório, então qualquer número per-repo seria uma cópia enganosa do org-wide. Veja a visão de incidentes no dashboard.", }, diff --git a/platform/src/app/[tenant]/dashboard/sections/DORAOverview.tsx b/platform/src/app/[tenant]/dashboard/sections/DORAOverview.tsx index 1b99a57..c4532b3 100644 --- a/platform/src/app/[tenant]/dashboard/sections/DORAOverview.tsx +++ b/platform/src/app/[tenant]/dashboard/sections/DORAOverview.tsx @@ -12,6 +12,7 @@ import { CardTitle, } from "@/components/ui/card"; import { useTranslation } from "@/hooks/useTranslation"; +import { MIN_EVALUATED_FOR_KPIS } from "@/lib/queries/dora"; import type { OrgDORA } from "@/types/org-summary"; /** @@ -23,14 +24,6 @@ import type { OrgDORA } from "@/types/org-summary"; */ const MIN_FAILED_FOR_CORRELATION = 10; -/** - * Below this many evaluated deploys, the four headline KPI cards (CFR in - * particular — a ratio) are one-or-two-events noise dressed up as a - * precise percentage. Same order of magnitude as MIN_FAILED_FOR_CORRELATION, - * applied here to the broader "evaluated" denominator rather than "failed". - */ -const MIN_EVALUATED_FOR_KPIS = 10; - interface Props { data: OrgDORA; } diff --git a/platform/src/app/[tenant]/repos/[repoName]/dora-card.tsx b/platform/src/app/[tenant]/repos/[repoName]/dora-card.tsx index 206239a..665ec5e 100644 --- a/platform/src/app/[tenant]/repos/[repoName]/dora-card.tsx +++ b/platform/src/app/[tenant]/repos/[repoName]/dora-card.tsx @@ -6,6 +6,7 @@ import { MetricCard } from "@/components/charts/MetricCard"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent } from "@/components/ui/card"; import { useTranslation } from "@/hooks/useTranslation"; +import { MIN_EVALUATED_FOR_KPIS } from "@/lib/queries/dora"; import type { RepoDORA } from "@/types/org-summary"; interface Props { @@ -15,6 +16,10 @@ interface Props { export function DORARepoCard({ data }: Props) { const { t } = useTranslation(); + const evaluatedDeploys = + data.deploymentsTotal - data.deploymentsPendingEvaluation; + const lowSample = evaluatedDeploys < MIN_EVALUATED_FOR_KPIS; + return (
@@ -55,6 +60,14 @@ export function DORARepoCard({ data }: Props) { value={formatHours(data.leadTimeSecondsMedian)} />
+ {lowSample && ( +

+ {t("repos.detail.dora.lowSample", { + threshold: MIN_EVALUATED_FOR_KPIS, + actual: evaluatedDeploys, + })} +

+ )}
@@ -144,9 +152,23 @@ export function InvestmentHotspots({ data }: InvestmentHotspotsProps) { {t("investHere.subtitle")} -
- - {t("investHere.empty")} +
+ + + {hasNoData ? t("investHere.noData") : t("investHere.empty")} +
diff --git a/platform/src/app/[tenant]/repos/[repoName]/page.tsx b/platform/src/app/[tenant]/repos/[repoName]/page.tsx index f52a8b1..43c9177 100644 --- a/platform/src/app/[tenant]/repos/[repoName]/page.tsx +++ b/platform/src/app/[tenant]/repos/[repoName]/page.tsx @@ -280,6 +280,14 @@ export default async function RepoDetailPage({ : "\u2014" } delta={stabDelta} + // stabilization_ratio defaults to 1.0 (100%) when there are no + // touched files \u2014 without this, an empty window reads as + // "perfectly stable" instead of "nothing to measure." + hint={ + latest?.commits_total === 0 + ? t("repos.detail.metrics.noActivityHint") + : undefined + } /> ): ReportMetrics { + return { + commits_total: 0, + commits_revert: 0, + revert_rate: 0, + churn_events: 0, + churn_lines_affected: 0, + files_touched: 0, + files_stabilized: 0, + stabilization_ratio: 0, + ...over, + } as ReportMetrics; +} + +describe("extractAdoptionSummary — flat threshold matches PP_STABLE", () => { + it("classifies a 2.5pp stabilization move as flat, not up (was flagged at 2pp before)", () => { + const summary = extractAdoptionSummary( + metrics({ + adoption_timeline: { + first_ai_commit_date: "2026-01-01", + adoption_ramp_start: "2026-01-15", + adoption_ramp_end: null, + adoption_confidence: "clear", + total_ai_commits: 10, + pre_adoption: metrics({ stabilization_ratio: 0.7 }), + post_adoption: metrics({ stabilization_ratio: 0.725 }), + }, + }), + ); + + const stab = summary?.deltas.find((d) => d.key === "stabilization"); + expect(stab?.deltaPp).toBeCloseTo(2.5, 5); + // 2.5pp is under the engine's own PP_STABLE (5pp) floor for "not noise" + // — the old 2pp threshold here would have called this "up". + expect(stab?.direction).toBe("flat"); + }); + + it("classifies a 6pp stabilization move as up (above PP_STABLE)", () => { + const summary = extractAdoptionSummary( + metrics({ + adoption_timeline: { + first_ai_commit_date: "2026-01-01", + adoption_ramp_start: "2026-01-15", + adoption_ramp_end: null, + adoption_confidence: "clear", + total_ai_commits: 10, + pre_adoption: metrics({ stabilization_ratio: 0.7 }), + post_adoption: metrics({ stabilization_ratio: 0.76 }), + }, + }), + ); + + const stab = summary?.deltas.find((d) => d.key === "stabilization"); + expect(stab?.deltaPp).toBeCloseTo(6, 5); + expect(stab?.direction).toBe("up"); + }); +}); diff --git a/platform/tests/detect-changes.test.ts b/platform/tests/detect-changes.test.ts new file mode 100644 index 0000000..443cc47 --- /dev/null +++ b/platform/tests/detect-changes.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; + +import { detectChanges, PP_STABLE } from "@/lib/queries/temporal"; +import type { TimeSeriesPoint } from "@/types/temporal"; + +function point(over: Partial): TimeSeriesPoint { + return { + date: "2026-01-01", + stabilization_ratio: null, + revert_rate: null, + churn_events: null, + commits_total: null, + ai_detection_coverage_pct: null, + ...over, + }; +} + +describe("detectChanges — thresholds match the engine's canonical PP_STABLE", () => { + it("flags a 6pp stabilization drop (was silent below the old 10pp threshold)", () => { + const current = point({ stabilization_ratio: 0.7 }); + const previous = point({ stabilization_ratio: 0.76 }); + const changes = detectChanges("repo", "id", current, previous); + expect(changes.some((c) => c.metric === "stabilization_ratio")).toBe(true); + }); + + it("stays silent on a stabilization move smaller than PP_STABLE", () => { + const current = point({ stabilization_ratio: 0.71 }); + const previous = point({ stabilization_ratio: 0.73 }); + const changes = detectChanges("repo", "id", current, previous); + expect(changes.some((c) => c.metric === "stabilization_ratio")).toBe(false); + }); + + it("flags a 6pp AI-coverage change (was silent below the old 15pp threshold)", () => { + const current = point({ ai_detection_coverage_pct: 20 }); + const previous = point({ ai_detection_coverage_pct: 14 }); + const changes = detectChanges("repo", "id", current, previous); + expect(changes.some((c) => c.metric === "ai_detection_coverage_pct")).toBe( + true, + ); + }); + + it("uses the same PP_STABLE floor for revert_rate as before (5pp already matched)", () => { + const atThreshold = detectChanges( + "repo", + "id", + point({ revert_rate: 0.1 }), + point({ revert_rate: 0.1 - PP_STABLE / 100 }), + ); + expect(atThreshold.some((c) => c.metric === "revert_rate")).toBe(true); + + const belowThreshold = detectChanges( + "repo", + "id", + point({ revert_rate: 0.1 }), + point({ revert_rate: 0.1 - PP_STABLE / 100 + 0.01 }), + ); + expect(belowThreshold.some((c) => c.metric === "revert_rate")).toBe(false); + }); +});