@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.
- package/dist/bin/internal.js +1415 -534
- package/dist/bin/templates/coverage-panel.html.fragment +20 -0
- package/dist/bin/templates/graph.css +82 -0
- package/dist/bin/templates/graph.html.template +17 -9
- package/dist/bin/templates/graph.js +185 -0
- package/dist/src/index.js +6 -0
- package/package.json +3 -3
- package/src/bin-internal/ac-coverage-backfill-finalize.ts +90 -0
- package/src/bin-internal/ac-coverage-backfill-prepare.ts +72 -0
- package/src/bin-internal/coverage-prepare.ts +123 -0
- package/src/bin-internal/fill-gap-finalize.ts +115 -0
- package/src/bin-internal/fill-gap-prepare.ts +150 -0
- package/src/bin-internal/graph-render.ts +32 -4
- package/src/bin-internal/index.ts +10 -0
- package/src/bin-internal/verify-prompts.ts +2 -0
- package/src/config/schema.ts +9 -0
- package/src/coverage/index.ts +29 -0
- package/src/coverage/report.ts +206 -0
- package/src/coverage/risk.ts +69 -0
- package/src/coverage/status.ts +76 -0
- package/src/coverage/types.ts +11 -0
- package/src/coverage/why.ts +122 -0
- package/src/graph/render.ts +16 -2
- package/src/graph/schema.ts +54 -1
- package/src/graph/store.ts +96 -6
- package/src/graph/templates/coverage-panel.html.fragment +20 -0
- package/src/graph/templates/graph.css +82 -0
- package/src/graph/templates/graph.html.template +17 -9
- package/src/graph/templates/graph.js +185 -0
- package/src/graph/types.ts +56 -1
|
@@ -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
|
+
}
|
package/src/graph/render.ts
CHANGED
|
@@ -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 {
|
|
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
|
}
|
package/src/graph/schema.ts
CHANGED
|
@@ -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([
|
|
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(
|
package/src/graph/store.ts
CHANGED
|
@@ -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
|
-
|
|
106
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
+
}
|