@xera-ai/core 0.9.8 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,69 @@
1
+ import type { Snapshot } from '../graph/types';
2
+ import { computeAcStatus } from './status';
3
+ import type { CoverageConfig } from './types';
4
+
5
+ export const RISK_WEIGHTS = {
6
+ criticalBoost: 2,
7
+ bugClassifications: new Set<string>(['REAL_BUG', 'TEST_OUTDATED']),
8
+ recencyBoosts: { recent: 2.0, withinWindow: 1.0, older: 0.5 },
9
+ recencyThresholdDays: 7,
10
+ } as const;
11
+
12
+ function daysBetween(a: Date, b: Date): number {
13
+ return Math.abs(a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24);
14
+ }
15
+
16
+ export function computeAreaRisk(
17
+ areaId: string,
18
+ snap: Snapshot,
19
+ config: CoverageConfig,
20
+ now: Date,
21
+ ): number {
22
+ const recentTickets = snap.edges
23
+ .filter((e) => e.kind === 'modifies' && e.to === areaId)
24
+ .map((e) => snap.tickets[e.from])
25
+ .filter((t): t is NonNullable<typeof t> => t !== undefined)
26
+ .filter((t) => daysBetween(now, new Date(t.fetchedAt)) <= config.staleAfterDays).length;
27
+
28
+ const pomsInArea = snap.edges
29
+ .filter((e) => e.kind === 'covers' && e.to === areaId)
30
+ .map((e) => e.from);
31
+ const scenariosInArea = new Set(
32
+ snap.edges.filter((e) => e.kind === 'uses' && pomsInArea.includes(e.to)).map((e) => e.from),
33
+ );
34
+ const recentBugs = snap.classifications
35
+ .filter((c) => scenariosInArea.has(c.scenarioId))
36
+ .filter((c) => RISK_WEIGHTS.bugClassifications.has(c.classification))
37
+ .filter((c) => daysBetween(now, new Date(c.ts)) <= config.staleAfterDays).length;
38
+
39
+ const criticalBoost = config.criticalAreas.includes(areaId) ? RISK_WEIGHTS.criticalBoost : 1;
40
+
41
+ return recentTickets * criticalBoost + recentBugs;
42
+ }
43
+
44
+ export function computeAcGapScore(
45
+ ticketId: string,
46
+ snap: Snapshot,
47
+ config: CoverageConfig,
48
+ now: Date,
49
+ ): number {
50
+ const ticket = snap.tickets[ticketId];
51
+ if (!ticket) return 0;
52
+
53
+ const acs = Object.values(snap.acNodes).filter((ac) => ac.ticketId === ticketId);
54
+ const unsatisfied = acs.filter(
55
+ (ac) => computeAcStatus(ac.id, snap, config.staleAfterDays, now) === 'UNSATISFIED',
56
+ ).length;
57
+ if (unsatisfied === 0) return 0;
58
+
59
+ const days = daysBetween(now, new Date(ticket.fetchedAt));
60
+ let boost: number;
61
+ if (days <= RISK_WEIGHTS.recencyThresholdDays) {
62
+ boost = RISK_WEIGHTS.recencyBoosts.recent;
63
+ } else if (days <= config.staleAfterDays) {
64
+ boost = RISK_WEIGHTS.recencyBoosts.withinWindow;
65
+ } else {
66
+ boost = RISK_WEIGHTS.recencyBoosts.older;
67
+ }
68
+ return unsatisfied * boost;
69
+ }
@@ -0,0 +1,76 @@
1
+ import type { Snapshot } from '../graph/types';
2
+
3
+ export type ScenarioStatus = 'PASSING' | 'NOT_PASSING';
4
+ export type AreaStatus = 'UNCOVERED' | 'STALE' | 'COVERED';
5
+ export type AcStatus = 'SATISFIED' | 'UNSATISFIED';
6
+ export type TicketStatus = 'COMPLETE' | 'INCOMPLETE';
7
+
8
+ function daysBetween(a: Date, b: Date): number {
9
+ return Math.abs(a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24);
10
+ }
11
+
12
+ export function computeScenarioStatus(
13
+ scenarioId: string,
14
+ snap: Snapshot,
15
+ windowDays: number,
16
+ now: Date,
17
+ ): ScenarioStatus {
18
+ const events = snap.classifications
19
+ .filter((c) => c.scenarioId === scenarioId)
20
+ .sort((a, b) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0));
21
+ const latest = events[0];
22
+ if (!latest) return 'NOT_PASSING';
23
+ if (latest.classification !== 'PASS') return 'NOT_PASSING';
24
+ if (daysBetween(now, new Date(latest.ts)) > windowDays) return 'NOT_PASSING';
25
+ return 'PASSING';
26
+ }
27
+
28
+ export function computeAreaStatus(
29
+ areaId: string,
30
+ snap: Snapshot,
31
+ windowDays: number,
32
+ now: Date,
33
+ ): AreaStatus {
34
+ const coveringPoms = snap.edges
35
+ .filter((e) => e.kind === 'covers' && e.to === areaId)
36
+ .map((e) => e.from);
37
+ if (coveringPoms.length === 0) return 'UNCOVERED';
38
+
39
+ const scenariosInArea = snap.edges
40
+ .filter((e) => e.kind === 'uses' && coveringPoms.includes(e.to))
41
+ .map((e) => e.from);
42
+ const anyPassing = scenariosInArea.some(
43
+ (sid) => computeScenarioStatus(sid, snap, windowDays, now) === 'PASSING',
44
+ );
45
+ return anyPassing ? 'COVERED' : 'STALE';
46
+ }
47
+
48
+ export function computeAcStatus(
49
+ acId: string,
50
+ snap: Snapshot,
51
+ windowDays: number,
52
+ now: Date,
53
+ ): AcStatus {
54
+ const edges = snap.edges.filter((e) => e.kind === 'satisfies' && e.to === acId);
55
+ if (edges.length === 0) return 'UNSATISFIED';
56
+ const anyPassing = edges.some(
57
+ (e) => computeScenarioStatus(e.from, snap, windowDays, now) === 'PASSING',
58
+ );
59
+ return anyPassing ? 'SATISFIED' : 'UNSATISFIED';
60
+ }
61
+
62
+ export function computeTicketStatus(
63
+ ticketId: string,
64
+ snap: Snapshot,
65
+ windowDays: number,
66
+ now: Date,
67
+ ): TicketStatus {
68
+ const acIds = Object.values(snap.acNodes)
69
+ .filter((ac) => ac.ticketId === ticketId)
70
+ .map((ac) => ac.id);
71
+ if (acIds.length === 0) return 'COMPLETE';
72
+ const allSatisfied = acIds.every(
73
+ (acId) => computeAcStatus(acId, snap, windowDays, now) === 'SATISFIED',
74
+ );
75
+ return allSatisfied ? 'COMPLETE' : 'INCOMPLETE';
76
+ }
@@ -0,0 +1,11 @@
1
+ export interface CoverageConfig {
2
+ staleAfterDays: number;
3
+ criticalAreas: string[];
4
+ autoSnapshotOnCoverage: boolean;
5
+ }
6
+
7
+ export const DEFAULT_COVERAGE_CONFIG: CoverageConfig = {
8
+ staleAfterDays: 30,
9
+ criticalAreas: [],
10
+ autoSnapshotOnCoverage: true,
11
+ };
@@ -0,0 +1,122 @@
1
+ import type { Snapshot } from '../graph/types';
2
+ import { computeAcGapScore, computeAreaRisk, RISK_WEIGHTS } from './risk';
3
+ import { computeAcStatus, computeAreaStatus, computeTicketStatus } from './status';
4
+ import type { CoverageConfig } from './types';
5
+
6
+ function daysBetween(a: Date, b: Date): number {
7
+ return Math.abs(a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24);
8
+ }
9
+
10
+ function pad(s: string, n: number): string {
11
+ return s.length >= n ? s : `${s}${' '.repeat(n - s.length)}`;
12
+ }
13
+
14
+ export function buildWhyArea(
15
+ areaId: string,
16
+ snap: Snapshot,
17
+ config: CoverageConfig,
18
+ now: Date,
19
+ ): string {
20
+ if (snap.areas[areaId] === undefined) return `Unknown area: ${areaId}\n`;
21
+
22
+ const status = computeAreaStatus(areaId, snap, config.staleAfterDays, now);
23
+ const isCritical = config.criticalAreas.includes(areaId);
24
+ const heading = isCritical ? `${status}, critical` : status;
25
+
26
+ const risk = computeAreaRisk(areaId, snap, config, now);
27
+ const recentTickets = snap.edges
28
+ .filter((e) => e.kind === 'modifies' && e.to === areaId)
29
+ .map((e) => snap.tickets[e.from])
30
+ .filter((t): t is NonNullable<typeof t> => t !== undefined)
31
+ .filter((t) => daysBetween(now, new Date(t.fetchedAt)) <= config.staleAfterDays);
32
+ const pomsInArea = snap.edges
33
+ .filter((e) => e.kind === 'covers' && e.to === areaId)
34
+ .map((e) => e.from);
35
+ const scenariosInArea = new Set(
36
+ snap.edges.filter((e) => e.kind === 'uses' && pomsInArea.includes(e.to)).map((e) => e.from),
37
+ );
38
+ const recentBugs = snap.classifications
39
+ .filter((c) => scenariosInArea.has(c.scenarioId))
40
+ .filter((c) => RISK_WEIGHTS.bugClassifications.has(c.classification))
41
+ .filter((c) => daysBetween(now, new Date(c.ts)) <= config.staleAfterDays);
42
+ const boost = isCritical ? 2 : 1;
43
+
44
+ const lines: string[] = [
45
+ '',
46
+ `Area: ${areaId} (${heading})`,
47
+ '',
48
+ `Risk score: ${risk}`,
49
+ ' recent_tickets × critical_boost + recent_bugs',
50
+ ` = ${recentTickets.length} × ${boost} + ${recentBugs.length} = ${risk}`,
51
+ '',
52
+ `Recent tickets (${recentTickets.length}, last ${config.staleAfterDays}d):`,
53
+ ];
54
+ for (const t of recentTickets) {
55
+ lines.push(` ${t.id} ${t.fetchedAt.slice(0, 10)} ${t.summary}`);
56
+ }
57
+ if (recentTickets.length === 0) lines.push(' (none)');
58
+ lines.push('');
59
+ if (recentBugs.length > 0) {
60
+ lines.push(`Recent bugs (${recentBugs.length}, last ${config.staleAfterDays}d):`);
61
+ for (const b of recentBugs) {
62
+ lines.push(` ${b.ts.slice(0, 10)} ${pad(b.classification, 14)} scenario ${b.scenarioId}`);
63
+ }
64
+ lines.push('');
65
+ }
66
+ if (status === 'UNCOVERED') {
67
+ lines.push('No POM covers this area. To draft scenarios:');
68
+ lines.push(` /xera-fill-gap ${areaId}`);
69
+ }
70
+ return `${lines.join('\n')}\n`;
71
+ }
72
+
73
+ export function buildWhyTicket(
74
+ ticketId: string,
75
+ snap: Snapshot,
76
+ config: CoverageConfig,
77
+ now: Date,
78
+ ): string {
79
+ const ticket = snap.tickets[ticketId];
80
+ if (!ticket) return `Unknown ticket: ${ticketId}\n`;
81
+
82
+ const status = computeTicketStatus(ticketId, snap, config.staleAfterDays, now);
83
+ const acs = Object.values(snap.acNodes)
84
+ .filter((ac) => ac.ticketId === ticketId)
85
+ .sort((a, b) => a.index - b.index);
86
+ const satisfiedCount = acs.filter(
87
+ (ac) => computeAcStatus(ac.id, snap, config.staleAfterDays, now) === 'SATISFIED',
88
+ ).length;
89
+ const gapScore = computeAcGapScore(ticketId, snap, config, now);
90
+
91
+ const days = daysBetween(now, new Date(ticket.fetchedAt));
92
+ let boostLabel: string;
93
+ if (days <= RISK_WEIGHTS.recencyThresholdDays) boostLabel = '×2.0';
94
+ else if (days <= config.staleAfterDays) boostLabel = '×1.0';
95
+ else boostLabel = '×0.5';
96
+
97
+ const lines: string[] = [
98
+ '',
99
+ `Ticket: ${ticketId} (${status}, ${satisfiedCount}/${acs.length} ACs covered)`,
100
+ ` Title: ${ticket.summary}`,
101
+ ` Fetched: ${ticket.fetchedAt.slice(0, 10)} (${Math.floor(days)}d ago, recency boost ${boostLabel})`,
102
+ ` AC gap score: ${gapScore}`,
103
+ '',
104
+ 'Acceptance Criteria:',
105
+ ];
106
+ for (const ac of acs) {
107
+ const acStatus = computeAcStatus(ac.id, snap, config.staleAfterDays, now);
108
+ const marker = acStatus === 'SATISFIED' ? '✓' : '✗';
109
+ const satisfyingScenarios = snap.edges
110
+ .filter((e) => e.kind === 'satisfies' && e.to === ac.id)
111
+ .map((e) => e.from);
112
+ const scenarioRef =
113
+ satisfyingScenarios.length > 0 ? ` — scenario "${satisfyingScenarios[0]}"` : '';
114
+ lines.push(` ${marker} AC-${ac.index} ${ac.text}${scenarioRef}`);
115
+ }
116
+ lines.push('');
117
+ if (status === 'INCOMPLETE') {
118
+ lines.push('To draft scenarios for unsatisfied ACs:');
119
+ lines.push(` /xera-fill-gap --ticket ${ticketId}`);
120
+ }
121
+ return `${lines.join('\n')}\n`;
122
+ }
@@ -1,7 +1,8 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
- import type { EdgeRecord, Snapshot } from './types';
4
+ import type { CoverageReport } from '../coverage/report';
5
+ import type { CoverageSnapshotPayload, EdgeRecord, Snapshot } from './types';
5
6
 
6
7
  export interface VisNode {
7
8
  id: string;
@@ -299,10 +300,16 @@ function statsToHuman(s: GraphStats): string {
299
300
  return `${s.tickets} tickets · ${s.scenarios} scenarios · ${s.poms} POMs · ${s.edges} edges`;
300
301
  }
301
302
 
303
+ export interface CoverageInput {
304
+ report: CoverageReport;
305
+ snapshots: CoverageSnapshotPayload[]; // Trend tab data (deduped by day, sorted asc)
306
+ }
307
+
302
308
  export interface RenderHtmlInput {
303
309
  data: { nodes: VisNode[]; edges: VisEdge[] };
304
310
  stats: GraphStats;
305
311
  generatedAt: string;
312
+ coverage?: CoverageInput; // NEW v0.8.1
306
313
  }
307
314
 
308
315
  export function renderHtml(input: RenderHtmlInput): string {
@@ -314,11 +321,18 @@ export function renderHtml(input: RenderHtmlInput): string {
314
321
  const graphJson = JSON.stringify(input.data);
315
322
  const statsHuman = statsToHuman(input.stats);
316
323
 
324
+ const coverageTabButton = input.coverage ? '<button data-tab="coverage">Coverage</button>' : '';
325
+ const coverageTabPanel = input.coverage ? loadTemplate('coverage-panel.html.fragment') : '';
326
+ const coverageJson = input.coverage ? JSON.stringify(input.coverage) : 'null';
327
+
317
328
  return template
318
329
  .replace('{{CSS}}', () => css)
319
330
  .replace('{{STATS}}', () => statsHuman)
320
331
  .replace('{{GENERATED_AT}}', () => input.generatedAt)
321
332
  .replace('{{VIS_NETWORK_JS}}', () => visNetwork)
322
333
  .replace('{{GRAPH_DATA}}', () => graphJson)
323
- .replace('{{INTERACTION_JS}}', () => js);
334
+ .replace('{{INTERACTION_JS}}', () => js)
335
+ .replace('{{COVERAGE_TAB_BUTTON}}', () => coverageTabButton)
336
+ .replace('{{COVERAGE_TAB_PANEL}}', () => coverageTabPanel)
337
+ .replace('{{COVERAGE_DATA}}', () => coverageJson);
324
338
  }
@@ -38,6 +38,7 @@ const scenarioGenerated = z
38
38
  priority: z.enum(['p0', 'p1', 'p2']),
39
39
  featureHash: z.string(),
40
40
  generatedAt: iso,
41
+ satisfiesAcs: z.array(z.number().int().nonnegative()).optional(),
41
42
  })
42
43
  .passthrough();
43
44
 
@@ -105,7 +106,16 @@ const classificationDisputed = z
105
106
 
106
107
  const edgeDiscovered = z
107
108
  .object({
108
- kind: z.enum(['tests', 'uses', 'covers', 'modifies', 'jira-linked', 'similar', 'ran']),
109
+ kind: z.enum([
110
+ 'tests',
111
+ 'uses',
112
+ 'covers',
113
+ 'modifies',
114
+ 'jira-linked',
115
+ 'similar',
116
+ 'ran',
117
+ 'satisfies',
118
+ ]),
109
119
  from: z.string(),
110
120
  to: z.string(),
111
121
  confidence: z.number().min(0).max(1).optional(),
@@ -113,6 +123,47 @@ const edgeDiscovered = z
113
123
  })
114
124
  .passthrough();
115
125
 
126
+ const coverageSnapshot = z
127
+ .object({
128
+ ts: iso,
129
+ windowDays: z.number().int().positive(),
130
+ areas: z.array(
131
+ z.object({
132
+ id: z.string().regex(/^[a-z0-9-]+$/),
133
+ status: z.enum(['UNCOVERED', 'STALE', 'COVERED']),
134
+ risk: z.number().nonnegative(),
135
+ breakdown: z.object({
136
+ recentTickets: z.number().int().nonnegative(),
137
+ recentBugs: z.number().int().nonnegative(),
138
+ criticalBoost: z.union([z.literal(1), z.literal(2)]),
139
+ }),
140
+ }),
141
+ ),
142
+ tickets: z.array(
143
+ z.object({
144
+ id: z.string().regex(/^[A-Z][A-Z0-9]*-\d+$/),
145
+ acCount: z.number().int().nonnegative(),
146
+ satisfiedCount: z.number().int().nonnegative(),
147
+ gapScore: z.number().nonnegative(),
148
+ }),
149
+ ),
150
+ })
151
+ .passthrough();
152
+
153
+ const acCoverageBackfilled = z
154
+ .object({
155
+ ts: iso,
156
+ ticketId: z.string().regex(/^[A-Z][A-Z0-9]*-\d+$/),
157
+ mappings: z.array(
158
+ z.object({
159
+ scenarioId: z.string().min(1),
160
+ satisfiesAcs: z.array(z.number().int().nonnegative()),
161
+ confidence: z.number().min(0).max(1),
162
+ }),
163
+ ),
164
+ })
165
+ .passthrough();
166
+
116
167
  const base = {
117
168
  event_id: z.string().min(20),
118
169
  schema_version: schemaV,
@@ -134,6 +185,8 @@ export const EventSchema = z.discriminatedUnion('type', [
134
185
  payload: classificationDisputed,
135
186
  }),
136
187
  z.object({ ...base, type: z.literal('edge.discovered'), payload: edgeDiscovered }),
188
+ z.object({ ...base, type: z.literal('coverage.snapshot'), payload: coverageSnapshot }),
189
+ z.object({ ...base, type: z.literal('ac-coverage.backfilled'), payload: acCoverageBackfilled }),
137
190
  ]);
138
191
 
139
192
  export function safeParseEvent(
@@ -11,6 +11,8 @@ import { dirname } from 'node:path';
11
11
  import { currentYyyyMm, graphPaths } from './paths';
12
12
  import { safeParseEvent } from './schema';
13
13
  import type {
14
+ ACNode,
15
+ Classification,
14
16
  EdgeRecord,
15
17
  Event,
16
18
  FailureNode,
@@ -98,12 +100,19 @@ export function deriveSnapshot(events: Event[]): Snapshot {
98
100
  const areas: Record<string, { id: string }> = {};
99
101
  const edges: EdgeRecord[] = [];
100
102
  const latestFailures: Record<string, FailureNode> = {};
103
+ const acNodes: Record<string, ACNode> = {};
104
+ const classifications: Array<{
105
+ scenarioId: string;
106
+ classification: Classification;
107
+ ts: string;
108
+ }> = [];
101
109
 
102
110
  for (const e of events) {
103
111
  switch (e.type) {
104
- case 'ticket.fetched':
105
- tickets[e.payload.ticketId] = {
106
- id: e.payload.ticketId,
112
+ case 'ticket.fetched': {
113
+ const tid = e.payload.ticketId;
114
+ tickets[tid] = {
115
+ id: tid,
107
116
  summary: e.payload.summary,
108
117
  ac: e.payload.ac,
109
118
  storyHash: e.payload.storyHash,
@@ -114,18 +123,33 @@ export function deriveSnapshot(events: Event[]): Snapshot {
114
123
  for (const link of e.payload.jiraLinks) {
115
124
  edges.push({
116
125
  kind: 'jira-linked',
117
- from: e.payload.ticketId,
126
+ from: tid,
118
127
  to: link.ticketId,
119
128
  source: `jira:${link.relation}`,
120
129
  discoveredAt: e.ts,
121
130
  });
122
131
  }
132
+ // NEW v0.8: drop prior ACNodes for this ticket, materialize fresh
133
+ for (const acId of Object.keys(acNodes)) {
134
+ if (acNodes[acId]?.ticketId === tid) delete acNodes[acId];
135
+ }
136
+ e.payload.ac.forEach((text, index) => {
137
+ const acId = `${tid}#ac-${index}`;
138
+ acNodes[acId] = { id: acId, ticketId: tid, index, text };
139
+ });
140
+ // Prune satisfies edges that target ACNodes no longer present
141
+ for (let i = edges.length - 1; i >= 0; i--) {
142
+ const ed = edges[i]!;
143
+ if (ed.kind !== 'satisfies') continue;
144
+ if (acNodes[ed.to] === undefined) edges.splice(i, 1);
145
+ }
123
146
  break;
147
+ }
124
148
  case 'ticket.enriched':
125
149
  if (tickets[e.payload.ticketId])
126
150
  tickets[e.payload.ticketId]!.enrichedAt = e.payload.enrichedAt;
127
151
  break;
128
- case 'scenario.generated':
152
+ case 'scenario.generated': {
129
153
  scenarios[e.payload.scenarioId] = {
130
154
  id: e.payload.scenarioId,
131
155
  ticketId: e.payload.ticketId,
@@ -142,7 +166,33 @@ export function deriveSnapshot(events: Event[]): Snapshot {
142
166
  source: 'xera-script',
143
167
  discoveredAt: e.ts,
144
168
  });
169
+ // NEW v0.8: drop prior eager satisfies edges for this scenario, then emit fresh
170
+ if (e.payload.satisfiesAcs && e.payload.satisfiesAcs.length > 0) {
171
+ for (let i = edges.length - 1; i >= 0; i--) {
172
+ const ed = edges[i]!;
173
+ if (
174
+ ed.kind === 'satisfies' &&
175
+ ed.from === e.payload.scenarioId &&
176
+ ed.source === 'xera-script'
177
+ ) {
178
+ edges.splice(i, 1);
179
+ }
180
+ }
181
+ for (const acIdx of e.payload.satisfiesAcs) {
182
+ const acId = `${e.payload.ticketId}#ac-${acIdx}`;
183
+ if (acNodes[acId] === undefined) continue;
184
+ edges.push({
185
+ kind: 'satisfies',
186
+ from: e.payload.scenarioId,
187
+ to: acId,
188
+ confidence: 1.0,
189
+ source: 'xera-script',
190
+ discoveredAt: e.ts,
191
+ });
192
+ }
193
+ }
145
194
  break;
195
+ }
146
196
  case 'pom.generated':
147
197
  poms[e.payload.pomId] = {
148
198
  id: e.payload.pomId,
@@ -192,7 +242,45 @@ export function deriveSnapshot(events: Event[]): Snapshot {
192
242
  }
193
243
  break;
194
244
  }
195
- // run.classified: not materialized in snapshot
245
+ case 'run.classified':
246
+ classifications.push({
247
+ scenarioId: e.payload.scenarioId,
248
+ classification: e.payload.classification,
249
+ ts: e.ts,
250
+ });
251
+ break;
252
+ case 'ac-coverage.backfilled': {
253
+ const { ts, ticketId, mappings } = e.payload;
254
+ // Remove prior backfill edges for this ticket (idempotent)
255
+ for (let i = edges.length - 1; i >= 0; i--) {
256
+ const ed = edges[i]!;
257
+ if (
258
+ ed.kind === 'satisfies' &&
259
+ ed.source === 'ac-coverage' &&
260
+ ed.to.startsWith(`${ticketId}#ac-`)
261
+ ) {
262
+ edges.splice(i, 1);
263
+ }
264
+ }
265
+ for (const m of mappings) {
266
+ for (const acIdx of m.satisfiesAcs) {
267
+ const acId = `${ticketId}#ac-${acIdx}`;
268
+ if (acNodes[acId] === undefined) continue;
269
+ edges.push({
270
+ kind: 'satisfies',
271
+ from: m.scenarioId,
272
+ to: acId,
273
+ confidence: m.confidence,
274
+ source: 'ac-coverage',
275
+ discoveredAt: ts,
276
+ });
277
+ }
278
+ }
279
+ break;
280
+ }
281
+ case 'coverage.snapshot':
282
+ // Read-side only — Trend tab queries these events directly from JSONL.
283
+ break;
196
284
  default:
197
285
  break;
198
286
  }
@@ -209,6 +297,8 @@ export function deriveSnapshot(events: Event[]): Snapshot {
209
297
  areas,
210
298
  edges,
211
299
  latest_failures: latestFailures,
300
+ acNodes,
301
+ classifications,
212
302
  };
213
303
  }
214
304
 
@@ -0,0 +1,20 @@
1
+ <section data-tab-panel="coverage" hidden>
2
+ <nav class="subtabs">
3
+ <button data-subtab="map" class="active">Map</button>
4
+ <button data-subtab="list">List</button>
5
+ <button data-subtab="trend">Trend</button>
6
+ </nav>
7
+ <div data-subpanel="map" class="active">
8
+ <p class="subpanel-hint">Area nodes are colored by status. Red = UNCOVERED, amber = STALE, green = COVERED. Other nodes neutral.</p>
9
+ <main id="coverage-map-canvas"></main>
10
+ </div>
11
+ <div data-subpanel="list" hidden>
12
+ <table id="coverage-list-table"><thead><tr><th>Status</th><th>Area</th><th>Risk</th><th>Recent tickets</th><th>Recent bugs</th></tr></thead><tbody></tbody></table>
13
+ <h3>AC Gaps</h3>
14
+ <table id="coverage-ac-table"><thead><tr><th>Ticket</th><th>Coverage</th><th>Gap</th><th>Unsatisfied</th></tr></thead><tbody></tbody></table>
15
+ </div>
16
+ <div data-subpanel="trend" hidden>
17
+ <p class="subpanel-hint">UNCOVERED + STALE area count over time (one point per day, latest snapshot wins).</p>
18
+ <div id="coverage-trend-svg"></div>
19
+ </div>
20
+ </section>
@@ -340,3 +340,85 @@ body {
340
340
  #footer span {
341
341
  color: #374151;
342
342
  }
343
+
344
+ /* ─── Coverage tab + subtabs ──────────────────────── */
345
+ .toplevel-tabs {
346
+ display: flex;
347
+ gap: 4px;
348
+ }
349
+ .toplevel-tabs button {
350
+ padding: 6px 12px;
351
+ border: 0;
352
+ background: transparent;
353
+ cursor: pointer;
354
+ color: #9ca3af;
355
+ }
356
+ .toplevel-tabs button.active {
357
+ border-bottom: 2px solid #3b82f6;
358
+ font-weight: bold;
359
+ color: #e2e8f0;
360
+ }
361
+ [data-tab-panel] {
362
+ display: none;
363
+ }
364
+ [data-tab-panel].active {
365
+ display: block;
366
+ }
367
+ .subtabs {
368
+ display: flex;
369
+ gap: 4px;
370
+ padding: 8px;
371
+ }
372
+ .subtabs button {
373
+ padding: 4px 10px;
374
+ border: 1px solid #d1d5db;
375
+ background: white;
376
+ cursor: pointer;
377
+ }
378
+ .subtabs button.active {
379
+ background: #3b82f6;
380
+ color: white;
381
+ }
382
+ [data-subpanel] {
383
+ padding: 12px;
384
+ }
385
+ .subpanel-hint {
386
+ color: #6b7280;
387
+ font-size: 13px;
388
+ margin: 4px 0 12px;
389
+ }
390
+ #coverage-list-table,
391
+ #coverage-ac-table {
392
+ border-collapse: collapse;
393
+ width: 100%;
394
+ font-size: 13px;
395
+ }
396
+ #coverage-list-table th,
397
+ #coverage-list-table td,
398
+ #coverage-ac-table th,
399
+ #coverage-ac-table td {
400
+ border: 1px solid #e5e7eb;
401
+ padding: 6px 10px;
402
+ text-align: left;
403
+ }
404
+ #coverage-list-table th {
405
+ background: #f9fafb;
406
+ cursor: pointer;
407
+ }
408
+ .status-uncovered {
409
+ background: #fee2e2;
410
+ }
411
+ .status-stale {
412
+ background: #fef3c7;
413
+ }
414
+ .status-covered {
415
+ background: #d1fae5;
416
+ }
417
+ #coverage-map-canvas {
418
+ width: 100%;
419
+ height: 600px;
420
+ }
421
+ #coverage-trend-svg {
422
+ width: 100%;
423
+ min-height: 300px;
424
+ }