@xera-ai/core 0.9.7 → 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.
Files changed (34) hide show
  1. package/dist/bin/internal.js +1415 -534
  2. package/dist/bin/templates/LICENSE-vis-network.txt +2 -0
  3. package/dist/bin/templates/coverage-panel.html.fragment +20 -0
  4. package/dist/bin/templates/graph.css +379 -43
  5. package/dist/bin/templates/graph.html.template +35 -15
  6. package/dist/bin/templates/graph.js +458 -56
  7. package/dist/bin/templates/vis-network.min.js +3 -24976
  8. package/dist/src/index.js +6 -0
  9. package/package.json +3 -3
  10. package/src/bin-internal/ac-coverage-backfill-finalize.ts +90 -0
  11. package/src/bin-internal/ac-coverage-backfill-prepare.ts +72 -0
  12. package/src/bin-internal/coverage-prepare.ts +123 -0
  13. package/src/bin-internal/fill-gap-finalize.ts +115 -0
  14. package/src/bin-internal/fill-gap-prepare.ts +150 -0
  15. package/src/bin-internal/graph-render.ts +32 -4
  16. package/src/bin-internal/index.ts +10 -0
  17. package/src/bin-internal/verify-prompts.ts +2 -0
  18. package/src/config/schema.ts +9 -0
  19. package/src/coverage/index.ts +29 -0
  20. package/src/coverage/report.ts +206 -0
  21. package/src/coverage/risk.ts +69 -0
  22. package/src/coverage/status.ts +76 -0
  23. package/src/coverage/types.ts +11 -0
  24. package/src/coverage/why.ts +122 -0
  25. package/src/graph/render.ts +16 -2
  26. package/src/graph/schema.ts +54 -1
  27. package/src/graph/store.ts +96 -6
  28. package/src/graph/templates/LICENSE-vis-network.txt +2 -0
  29. package/src/graph/templates/coverage-panel.html.fragment +20 -0
  30. package/src/graph/templates/graph.css +379 -43
  31. package/src/graph/templates/graph.html.template +35 -15
  32. package/src/graph/templates/graph.js +458 -56
  33. package/src/graph/templates/vis-network.min.js +3 -24976
  34. package/src/graph/types.ts +56 -1
@@ -14,6 +14,8 @@ const IN_SCOPE_PROMPTS = [
14
14
  'extract-areas.md',
15
15
  'similarity-match.md',
16
16
  'classify-outdated.md',
17
+ 'map-ac-to-scenarios.md',
18
+ 'propose-scenarios.md', // NEW v0.8.2
17
19
  ] as const;
18
20
 
19
21
  const REQUIRED_SECTION_HEADING = '## Handling untrusted input';
@@ -111,6 +111,14 @@ const RunSchema = z
111
111
  })
112
112
  .prefault({});
113
113
 
114
+ const CoverageSchema = z
115
+ .object({
116
+ staleAfterDays: z.number().int().positive().default(30),
117
+ criticalAreas: z.array(z.string().regex(/^[a-z0-9-]+$/)).default([]),
118
+ autoSnapshotOnCoverage: z.boolean().default(true),
119
+ })
120
+ .prefault({});
121
+
114
122
  export const XeraConfigSchema = z
115
123
  .object({
116
124
  jira: JiraSchema,
@@ -119,6 +127,7 @@ export const XeraConfigSchema = z
119
127
  ai: AISchema,
120
128
  reporting: ReportingSchema,
121
129
  run: RunSchema.prefault({}),
130
+ coverage: CoverageSchema,
122
131
  adapters: z
123
132
  .array(z.enum(['web', 'http']))
124
133
  .min(1)
@@ -0,0 +1,29 @@
1
+ export {
2
+ type AreaReportRow,
3
+ buildCoverageReport,
4
+ type CoverageReport,
5
+ type RenderOptions,
6
+ renderMarkdown,
7
+ type TicketReportRow,
8
+ } from './report';
9
+ export {
10
+ computeAcGapScore,
11
+ computeAreaRisk,
12
+ RISK_WEIGHTS,
13
+ } from './risk';
14
+ export {
15
+ type AcStatus,
16
+ type AreaStatus,
17
+ computeAcStatus,
18
+ computeAreaStatus,
19
+ computeScenarioStatus,
20
+ computeTicketStatus,
21
+ type ScenarioStatus,
22
+ type TicketStatus,
23
+ } from './status';
24
+ export type { CoverageConfig } from './types';
25
+ export { DEFAULT_COVERAGE_CONFIG } from './types';
26
+ export {
27
+ buildWhyArea,
28
+ buildWhyTicket,
29
+ } from './why';
@@ -0,0 +1,206 @@
1
+ import type { Snapshot } from '../graph/types';
2
+ import { computeAcGapScore, computeAreaRisk, RISK_WEIGHTS } from './risk';
3
+ import { type AreaStatus, computeAcStatus, computeAreaStatus, computeTicketStatus } from './status';
4
+ import type { CoverageConfig } from './types';
5
+
6
+ export interface AreaReportRow {
7
+ id: string;
8
+ status: AreaStatus;
9
+ risk: number;
10
+ breakdown: {
11
+ recentTickets: number;
12
+ recentBugs: number;
13
+ criticalBoost: 1 | 2;
14
+ };
15
+ }
16
+
17
+ export interface TicketReportRow {
18
+ id: string;
19
+ summary: string;
20
+ acCount: number;
21
+ satisfiedCount: number;
22
+ gapScore: number;
23
+ unsatisfiedAcs: Array<{ index: number; text: string }>;
24
+ }
25
+
26
+ export interface CoverageReport {
27
+ generatedAt: string;
28
+ windowDays: number;
29
+ areas: AreaReportRow[];
30
+ tickets: TicketReportRow[];
31
+ acBackfillNeeded: boolean;
32
+ }
33
+
34
+ const STATUS_RANK: Record<AreaStatus, number> = {
35
+ UNCOVERED: 0,
36
+ STALE: 1,
37
+ COVERED: 2,
38
+ };
39
+
40
+ function daysBetween(a: Date, b: Date): number {
41
+ return Math.abs(a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24);
42
+ }
43
+
44
+ export function buildCoverageReport(
45
+ snap: Snapshot,
46
+ config: CoverageConfig,
47
+ now: Date,
48
+ ): CoverageReport {
49
+ const areas: AreaReportRow[] = Object.keys(snap.areas).map((areaId) => {
50
+ const status = computeAreaStatus(areaId, snap, config.staleAfterDays, now);
51
+ const risk = computeAreaRisk(areaId, snap, config, now);
52
+
53
+ const recentTickets = snap.edges
54
+ .filter((e) => e.kind === 'modifies' && e.to === areaId)
55
+ .map((e) => snap.tickets[e.from])
56
+ .filter((t): t is NonNullable<typeof t> => t !== undefined)
57
+ .filter((t) => daysBetween(now, new Date(t.fetchedAt)) <= config.staleAfterDays).length;
58
+ const pomsInArea = snap.edges
59
+ .filter((e) => e.kind === 'covers' && e.to === areaId)
60
+ .map((e) => e.from);
61
+ const scenariosInArea = new Set(
62
+ snap.edges.filter((e) => e.kind === 'uses' && pomsInArea.includes(e.to)).map((e) => e.from),
63
+ );
64
+ const recentBugs = snap.classifications
65
+ .filter((c) => scenariosInArea.has(c.scenarioId))
66
+ .filter((c) => RISK_WEIGHTS.bugClassifications.has(c.classification))
67
+ .filter((c) => daysBetween(now, new Date(c.ts)) <= config.staleAfterDays).length;
68
+ const criticalBoost: 1 | 2 = config.criticalAreas.includes(areaId) ? 2 : 1;
69
+
70
+ return {
71
+ id: areaId,
72
+ status,
73
+ risk,
74
+ breakdown: { recentTickets, recentBugs, criticalBoost },
75
+ };
76
+ });
77
+
78
+ areas.sort((a, b) => {
79
+ if (STATUS_RANK[a.status] !== STATUS_RANK[b.status]) {
80
+ return STATUS_RANK[a.status] - STATUS_RANK[b.status];
81
+ }
82
+ if (a.status === 'COVERED') return a.id.localeCompare(b.id);
83
+ if (b.risk !== a.risk) return b.risk - a.risk;
84
+ return a.id.localeCompare(b.id);
85
+ });
86
+
87
+ const tickets: TicketReportRow[] = Object.values(snap.tickets)
88
+ .filter((t) => computeTicketStatus(t.id, snap, config.staleAfterDays, now) === 'INCOMPLETE')
89
+ .map((t) => {
90
+ const acs = Object.values(snap.acNodes)
91
+ .filter((ac) => ac.ticketId === t.id)
92
+ .sort((a, b) => a.index - b.index);
93
+ const unsatisfiedAcs = acs
94
+ .filter((ac) => computeAcStatus(ac.id, snap, config.staleAfterDays, now) === 'UNSATISFIED')
95
+ .map((ac) => ({ index: ac.index, text: ac.text }));
96
+ return {
97
+ id: t.id,
98
+ summary: t.summary,
99
+ acCount: acs.length,
100
+ satisfiedCount: acs.length - unsatisfiedAcs.length,
101
+ gapScore: computeAcGapScore(t.id, snap, config, now),
102
+ unsatisfiedAcs,
103
+ };
104
+ })
105
+ .sort((a, b) => b.gapScore - a.gapScore || a.id.localeCompare(b.id));
106
+
107
+ return {
108
+ generatedAt: now.toISOString(),
109
+ windowDays: config.staleAfterDays,
110
+ areas,
111
+ tickets,
112
+ acBackfillNeeded: needsBackfill(snap),
113
+ };
114
+ }
115
+
116
+ function needsBackfill(snap: Snapshot): boolean {
117
+ for (const ticket of Object.values(snap.tickets)) {
118
+ const acsForTicket = Object.values(snap.acNodes).filter((ac) => ac.ticketId === ticket.id);
119
+ if (acsForTicket.length === 0) continue;
120
+ const scenariosForTicket = Object.values(snap.scenarios).filter(
121
+ (s) => s.ticketId === ticket.id,
122
+ );
123
+ if (scenariosForTicket.length === 0) continue;
124
+ const hasAnyEdge = snap.edges.some(
125
+ (e) => e.kind === 'satisfies' && acsForTicket.some((ac) => ac.id === e.to),
126
+ );
127
+ if (!hasAnyEdge) return true;
128
+ }
129
+ return false;
130
+ }
131
+
132
+ export interface RenderOptions {
133
+ includeCovered?: boolean;
134
+ }
135
+
136
+ function pad(s: string, n: number): string {
137
+ return s.length >= n ? s : `${s}${' '.repeat(n - s.length)}`;
138
+ }
139
+
140
+ export function renderMarkdown(report: CoverageReport, options: RenderOptions = {}): string {
141
+ const lines: string[] = [];
142
+ const dateOnly = report.generatedAt.slice(0, 10);
143
+ lines.push('', `Coverage report — generated ${dateOnly} · window ${report.windowDays}d`, '');
144
+
145
+ const uncovered = report.areas.filter((a) => a.status === 'UNCOVERED');
146
+ if (uncovered.length > 0) {
147
+ lines.push(
148
+ `UNCOVERED — ${uncovered.length} area${uncovered.length === 1 ? '' : 's'}, sorted by risk`,
149
+ );
150
+ lines.push('');
151
+ uncovered.forEach((a, i) => {
152
+ const parts: string[] = [`${a.breakdown.recentTickets} tickets`];
153
+ if (a.breakdown.recentBugs > 0) parts.push(`${a.breakdown.recentBugs} bugs`);
154
+ if (a.breakdown.criticalBoost === 2) parts.push('critical ×2');
155
+ lines.push(` #${i + 1} ${pad(a.id, 10)} risk ${a.risk} ${parts.join(' · ')}`);
156
+ });
157
+ lines.push('');
158
+ }
159
+
160
+ const stale = report.areas.filter((a) => a.status === 'STALE');
161
+ if (stale.length > 0) {
162
+ lines.push(
163
+ `STALE — ${stale.length} area${stale.length === 1 ? '' : 's'}, has tests but no PASS in ${report.windowDays}d`,
164
+ );
165
+ lines.push('');
166
+ stale.forEach((a, i) => {
167
+ lines.push(` #${i + 1} ${pad(a.id, 10)} (see --why ${a.id} for details)`);
168
+ });
169
+ lines.push('');
170
+ }
171
+
172
+ if (report.tickets.length > 0) {
173
+ lines.push(
174
+ `AC GAPS — ${report.tickets.length} ticket${report.tickets.length === 1 ? '' : 's'} with unsatisfied acceptance criteria`,
175
+ );
176
+ lines.push('');
177
+ for (const t of report.tickets) {
178
+ lines.push(
179
+ ` ${t.id} ${t.satisfiedCount}/${t.acCount} ACs covered · gap_score ${t.gapScore}`,
180
+ );
181
+ for (const ac of t.unsatisfiedAcs) {
182
+ lines.push(` ✗ AC-${ac.index} ${ac.text}`);
183
+ }
184
+ lines.push('');
185
+ }
186
+ }
187
+
188
+ const covered = report.areas.filter((a) => a.status === 'COVERED');
189
+ if (covered.length > 0) {
190
+ if (options.includeCovered) {
191
+ lines.push(`COVERED — ${covered.length} area${covered.length === 1 ? '' : 's'}`);
192
+ lines.push('');
193
+ covered.forEach((a, i) => {
194
+ lines.push(` #${i + 1} ${pad(a.id, 10)} ok`);
195
+ });
196
+ lines.push('');
197
+ } else {
198
+ lines.push(
199
+ `COVERED — ${covered.length} area${covered.length === 1 ? '' : 's'} (collapsed; show with --all)`,
200
+ );
201
+ lines.push('');
202
+ }
203
+ }
204
+
205
+ return lines.join('\n');
206
+ }
@@ -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(