@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.
- package/dist/bin/internal.js +1415 -534
- package/dist/bin/templates/LICENSE-vis-network.txt +2 -0
- package/dist/bin/templates/coverage-panel.html.fragment +20 -0
- package/dist/bin/templates/graph.css +379 -43
- package/dist/bin/templates/graph.html.template +35 -15
- package/dist/bin/templates/graph.js +458 -56
- package/dist/bin/templates/vis-network.min.js +3 -24976
- 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/LICENSE-vis-network.txt +2 -0
- package/src/graph/templates/coverage-panel.html.fragment +20 -0
- package/src/graph/templates/graph.css +379 -43
- package/src/graph/templates/graph.html.template +35 -15
- package/src/graph/templates/graph.js +458 -56
- package/src/graph/templates/vis-network.min.js +3 -24976
- package/src/graph/types.ts +56 -1
package/src/config/schema.ts
CHANGED
|
@@ -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
|
+
}
|
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(
|