@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,115 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
const ProposalsSchema = z.object({
|
|
6
|
+
proposals: z.array(
|
|
7
|
+
z.object({
|
|
8
|
+
id: z.string().min(1),
|
|
9
|
+
ticketId: z.string().min(1),
|
|
10
|
+
title: z.string().min(1),
|
|
11
|
+
rationale: z.string().min(1),
|
|
12
|
+
gherkin: z.string().min(1),
|
|
13
|
+
satisfiesAcs: z.array(z.number().int().nonnegative()),
|
|
14
|
+
}),
|
|
15
|
+
),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
interface ParsedArgs {
|
|
19
|
+
accept: string;
|
|
20
|
+
ticket: string;
|
|
21
|
+
source?: string;
|
|
22
|
+
force: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
26
|
+
let accept: string | undefined;
|
|
27
|
+
let ticket: string | undefined;
|
|
28
|
+
let source: string | undefined;
|
|
29
|
+
let force = false;
|
|
30
|
+
for (let i = 0; i < argv.length; i++) {
|
|
31
|
+
const a = argv[i];
|
|
32
|
+
if (a === '--accept') {
|
|
33
|
+
const v = argv[++i];
|
|
34
|
+
if (v !== undefined) accept = v;
|
|
35
|
+
} else if (a === '--ticket') {
|
|
36
|
+
const v = argv[++i];
|
|
37
|
+
if (v !== undefined) ticket = v;
|
|
38
|
+
} else if (a === '--source') {
|
|
39
|
+
const v = argv[++i];
|
|
40
|
+
if (v !== undefined) source = v;
|
|
41
|
+
} else if (a === '--force') {
|
|
42
|
+
force = true;
|
|
43
|
+
} else if (a === '--help-stub') {
|
|
44
|
+
/* no-op */
|
|
45
|
+
} else {
|
|
46
|
+
return { error: `unknown flag: ${a}` };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (!accept || !ticket) return { error: 'required: --accept <proposal-id> --ticket <TICKET>' };
|
|
50
|
+
const out: ParsedArgs = { accept, ticket, force };
|
|
51
|
+
if (source !== undefined) out.source = source;
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function formatDraft(
|
|
56
|
+
ticketId: string,
|
|
57
|
+
proposal: z.infer<typeof ProposalsSchema>['proposals'][number],
|
|
58
|
+
): string {
|
|
59
|
+
const lines = [
|
|
60
|
+
`# Draft scenario for ${ticketId}`,
|
|
61
|
+
'',
|
|
62
|
+
`> ${proposal.rationale}`,
|
|
63
|
+
'',
|
|
64
|
+
proposal.gherkin,
|
|
65
|
+
'',
|
|
66
|
+
];
|
|
67
|
+
if (proposal.satisfiesAcs.length > 0) {
|
|
68
|
+
lines.push(`<!-- satisfiesAcs: [${proposal.satisfiesAcs.join(', ')}] -->`);
|
|
69
|
+
lines.push('');
|
|
70
|
+
}
|
|
71
|
+
return lines.join('\n');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function fillGapFinalizeCmd(argv: string[]): Promise<number> {
|
|
75
|
+
if (argv.includes('--help-stub')) {
|
|
76
|
+
/* test scaffold no-op */
|
|
77
|
+
}
|
|
78
|
+
const parsed = parseArgs(argv);
|
|
79
|
+
if ('error' in parsed) {
|
|
80
|
+
console.error(`[fill-gap-finalize] ${parsed.error}`);
|
|
81
|
+
return 1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const cwd = process.cwd();
|
|
85
|
+
const sourcePath = parsed.source ?? join(cwd, '.xera/coverage/proposals.json');
|
|
86
|
+
if (!existsSync(sourcePath)) {
|
|
87
|
+
console.error(`[fill-gap-finalize] source not found: ${sourcePath}`);
|
|
88
|
+
return 2;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let proposals: z.infer<typeof ProposalsSchema>;
|
|
92
|
+
try {
|
|
93
|
+
const raw = JSON.parse(readFileSync(sourcePath, 'utf8'));
|
|
94
|
+
proposals = ProposalsSchema.parse(raw);
|
|
95
|
+
} catch (e) {
|
|
96
|
+
console.error(`[fill-gap-finalize] invalid proposals: ${(e as Error).message}`);
|
|
97
|
+
return 2;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const proposal = proposals.proposals.find((p) => p.id === parsed.accept);
|
|
101
|
+
if (!proposal) {
|
|
102
|
+
console.error(`[fill-gap-finalize] proposal id "${parsed.accept}" not in source`);
|
|
103
|
+
return 2;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const ticketDir = join(cwd, '.xera', parsed.ticket);
|
|
107
|
+
mkdirSync(ticketDir, { recursive: true });
|
|
108
|
+
const draftPath = join(ticketDir, 'feature.draft.md');
|
|
109
|
+
if (existsSync(draftPath) && !parsed.force) {
|
|
110
|
+
console.error(`[fill-gap-finalize] ${draftPath} exists; pass --force to overwrite`);
|
|
111
|
+
return 3;
|
|
112
|
+
}
|
|
113
|
+
writeFileSync(draftPath, formatDraft(parsed.ticket, proposal));
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { deriveSnapshot, loadAllEvents } from '../graph/store';
|
|
4
|
+
import type { Snapshot } from '../graph/types';
|
|
5
|
+
|
|
6
|
+
interface AreaContext {
|
|
7
|
+
mode: 'area';
|
|
8
|
+
area: string;
|
|
9
|
+
tickets: Array<{ id: string; summary: string; ac: string[] }>;
|
|
10
|
+
existingScenarios: Array<{ areaSlug: string; gherkin: string }>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface TicketContext {
|
|
14
|
+
mode: 'ticket';
|
|
15
|
+
ticket: { id: string; summary: string; ac: string[] };
|
|
16
|
+
unsatisfiedAcs: Array<{ index: number; text: string }>;
|
|
17
|
+
existingScenarios: Array<{ scenarioId: string; name: string; gherkin: string }>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface ParsedArgs {
|
|
21
|
+
area?: string;
|
|
22
|
+
ticket?: string;
|
|
23
|
+
outputDir?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
27
|
+
const args: ParsedArgs = {};
|
|
28
|
+
for (let i = 0; i < argv.length; i++) {
|
|
29
|
+
const a = argv[i];
|
|
30
|
+
if (a === '--area') {
|
|
31
|
+
const v = argv[++i];
|
|
32
|
+
if (v !== undefined) args.area = v;
|
|
33
|
+
} else if (a === '--ticket') {
|
|
34
|
+
const v = argv[++i];
|
|
35
|
+
if (v !== undefined) args.ticket = v;
|
|
36
|
+
} else if (a === '--output-dir') {
|
|
37
|
+
const v = argv[++i];
|
|
38
|
+
if (v !== undefined) args.outputDir = v;
|
|
39
|
+
} else if (a === '--help-stub') {
|
|
40
|
+
/* no-op */
|
|
41
|
+
} else {
|
|
42
|
+
return { error: `unknown flag: ${a}` };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (!args.area && !args.ticket) return { error: 'one of --area or --ticket required' };
|
|
46
|
+
if (args.area && args.ticket) return { error: '--area and --ticket are mutually exclusive' };
|
|
47
|
+
return args;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function buildAreaContext(snap: Snapshot, area: string): AreaContext | null {
|
|
51
|
+
// Use modifiesAreas on ticket nodes (set from ticket.fetched) to find tickets for this area.
|
|
52
|
+
// edge.discovered with kind='modifies' is an additional source, but ticket.fetched also
|
|
53
|
+
// populates ticket.modifiesAreas directly — so check both.
|
|
54
|
+
const edgeTicketIds = new Set(
|
|
55
|
+
snap.edges.filter((e) => e.kind === 'modifies' && e.to === area).map((e) => e.from),
|
|
56
|
+
);
|
|
57
|
+
const ticketsForArea = Object.values(snap.tickets).filter(
|
|
58
|
+
(t) => t.modifiesAreas.includes(area) || edgeTicketIds.has(t.id),
|
|
59
|
+
);
|
|
60
|
+
if (ticketsForArea.length === 0) return null;
|
|
61
|
+
|
|
62
|
+
// existingScenarios from other areas — limit to 3 to keep prompt input small
|
|
63
|
+
const scenariosFromOtherAreas = Object.values(snap.scenarios)
|
|
64
|
+
.filter((s) => {
|
|
65
|
+
const t = snap.tickets[s.ticketId];
|
|
66
|
+
if (!t) return false;
|
|
67
|
+
return !t.modifiesAreas.includes(area) && t.modifiesAreas.length > 0;
|
|
68
|
+
})
|
|
69
|
+
.slice(0, 3)
|
|
70
|
+
.map((s) => {
|
|
71
|
+
const ownerTicket = snap.tickets[s.ticketId];
|
|
72
|
+
const areaSlug = ownerTicket?.modifiesAreas[0] ?? 'unknown';
|
|
73
|
+
return { areaSlug, gherkin: s.gherkin };
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
mode: 'area',
|
|
78
|
+
area,
|
|
79
|
+
tickets: ticketsForArea.map((t) => ({ id: t.id, summary: t.summary, ac: t.ac })),
|
|
80
|
+
existingScenarios: scenariosFromOtherAreas,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function buildTicketContext(snap: Snapshot, ticketId: string): TicketContext | null {
|
|
85
|
+
const ticket = snap.tickets[ticketId];
|
|
86
|
+
if (!ticket) return null;
|
|
87
|
+
|
|
88
|
+
const acNodesForTicket = Object.values(snap.acNodes)
|
|
89
|
+
.filter((ac) => ac.ticketId === ticketId)
|
|
90
|
+
.sort((a, b) => a.index - b.index);
|
|
91
|
+
const satisfiedAcIds = new Set(
|
|
92
|
+
snap.edges
|
|
93
|
+
.filter((e) => e.kind === 'satisfies' && acNodesForTicket.some((ac) => ac.id === e.to))
|
|
94
|
+
.map((e) => e.to),
|
|
95
|
+
);
|
|
96
|
+
const unsatisfiedAcs = acNodesForTicket
|
|
97
|
+
.filter((ac) => !satisfiedAcIds.has(ac.id))
|
|
98
|
+
.map((ac) => ({ index: ac.index, text: ac.text }));
|
|
99
|
+
|
|
100
|
+
if (unsatisfiedAcs.length === 0) return null;
|
|
101
|
+
|
|
102
|
+
const existingScenarios = Object.values(snap.scenarios)
|
|
103
|
+
.filter((s) => s.ticketId === ticketId)
|
|
104
|
+
.map((s) => ({ scenarioId: s.id, name: s.name, gherkin: s.gherkin }));
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
mode: 'ticket',
|
|
108
|
+
ticket: { id: ticket.id, summary: ticket.summary, ac: ticket.ac },
|
|
109
|
+
unsatisfiedAcs,
|
|
110
|
+
existingScenarios,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function fillGapPrepareCmd(argv: string[]): Promise<number> {
|
|
115
|
+
const parsed = parseArgs(argv);
|
|
116
|
+
if ('error' in parsed) {
|
|
117
|
+
console.error(`[fill-gap-prepare] ${parsed.error}`);
|
|
118
|
+
return 1;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const cwd = process.cwd();
|
|
122
|
+
const snap = deriveSnapshot(loadAllEvents(cwd));
|
|
123
|
+
|
|
124
|
+
let context: AreaContext | TicketContext | null;
|
|
125
|
+
let scope: string;
|
|
126
|
+
if (parsed.area) {
|
|
127
|
+
context = buildAreaContext(snap, parsed.area);
|
|
128
|
+
scope = parsed.area;
|
|
129
|
+
if (!context) {
|
|
130
|
+
console.error(
|
|
131
|
+
`[fill-gap-prepare] area "${parsed.area}" has no tickets modifying it; cannot fill`,
|
|
132
|
+
);
|
|
133
|
+
return 2;
|
|
134
|
+
}
|
|
135
|
+
} else {
|
|
136
|
+
context = buildTicketContext(snap, parsed.ticket!);
|
|
137
|
+
scope = parsed.ticket!;
|
|
138
|
+
if (!context) {
|
|
139
|
+
console.error(
|
|
140
|
+
`[fill-gap-prepare] ticket "${parsed.ticket}" not found or has no unsatisfied ACs`,
|
|
141
|
+
);
|
|
142
|
+
return 2;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const outDir = parsed.outputDir ?? join(cwd, '.xera/coverage', scope);
|
|
147
|
+
mkdirSync(outDir, { recursive: true });
|
|
148
|
+
writeFileSync(join(outDir, 'context.json'), JSON.stringify(context, null, 2));
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { mkdirSync, renameSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
|
-
import type { RenderOpts } from '../graph/render';
|
|
3
|
+
import type { CoverageInput, RenderOpts } from '../graph/render';
|
|
4
4
|
import { renderHtml, transformForVisNetwork } from '../graph/render';
|
|
5
5
|
import { deriveSnapshot, loadAllEvents } from '../graph/store';
|
|
6
|
+
import type { CoverageSnapshotPayload, Event } from '../graph/types';
|
|
6
7
|
|
|
7
8
|
function parseDepth(s: string | undefined): 1 | 2 | 3 {
|
|
8
9
|
const n = s ? Number.parseInt(s, 10) : 2;
|
|
@@ -21,18 +22,21 @@ export async function graphRenderCmd(argv: string[]): Promise<number> {
|
|
|
21
22
|
let ticketId: string | undefined;
|
|
22
23
|
let since: string | undefined;
|
|
23
24
|
let depth: 1 | 2 | 3 = 2;
|
|
25
|
+
let includeCoverage = false;
|
|
24
26
|
|
|
25
27
|
for (let i = 0; i < argv.length; i++) {
|
|
26
28
|
if (argv[i] === '--out') outPath = argv[++i];
|
|
27
29
|
else if (argv[i] === '--ticket') ticketId = argv[++i];
|
|
28
30
|
else if (argv[i] === '--since') since = argv[++i];
|
|
29
31
|
else if (argv[i] === '--depth') depth = parseDepth(argv[++i]);
|
|
32
|
+
else if (argv[i] === '--include-coverage') includeCoverage = true;
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
const repoRoot = process.cwd();
|
|
33
36
|
const finalPath = outPath ?? join(repoRoot, '.xera/graph.html');
|
|
34
37
|
|
|
35
|
-
const
|
|
38
|
+
const events = loadAllEvents(repoRoot);
|
|
39
|
+
const snap = deriveSnapshot(events);
|
|
36
40
|
const totalNodeCount =
|
|
37
41
|
Object.keys(snap.tickets).length +
|
|
38
42
|
Object.keys(snap.scenarios).length +
|
|
@@ -55,8 +59,32 @@ export async function graphRenderCmd(argv: string[]): Promise<number> {
|
|
|
55
59
|
if (ticketId) opts.ticketId = ticketId;
|
|
56
60
|
if (since) opts.since = since;
|
|
57
61
|
|
|
62
|
+
let coverage: CoverageInput | undefined;
|
|
63
|
+
if (includeCoverage) {
|
|
64
|
+
const reportPath = join(repoRoot, '.xera/coverage/report.json');
|
|
65
|
+
if (existsSync(reportPath)) {
|
|
66
|
+
const report = JSON.parse(readFileSync(reportPath, 'utf8'));
|
|
67
|
+
const snapshots = events
|
|
68
|
+
.filter(
|
|
69
|
+
(e): e is Extract<Event, { type: 'coverage.snapshot' }> => e.type === 'coverage.snapshot',
|
|
70
|
+
)
|
|
71
|
+
.map((e) => e.payload as CoverageSnapshotPayload);
|
|
72
|
+
coverage = { report, snapshots };
|
|
73
|
+
} else {
|
|
74
|
+
console.warn(
|
|
75
|
+
'[graph-render] --include-coverage: report.json not found; run /xera-coverage first',
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
58
80
|
const data = transformForVisNetwork(snap, opts);
|
|
59
|
-
const
|
|
81
|
+
const renderInput: Parameters<typeof renderHtml>[0] = {
|
|
82
|
+
data,
|
|
83
|
+
stats: data.stats,
|
|
84
|
+
generatedAt: new Date().toISOString(),
|
|
85
|
+
};
|
|
86
|
+
if (coverage) renderInput.coverage = coverage;
|
|
87
|
+
const html = renderHtml(renderInput);
|
|
60
88
|
|
|
61
89
|
mkdirSync(dirname(finalPath), { recursive: true });
|
|
62
90
|
const tmpPath = `${finalPath}.tmp`;
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { acCoverageBackfillFinalizeCmd } from './ac-coverage-backfill-finalize';
|
|
2
|
+
import { acCoverageBackfillPrepareCmd } from './ac-coverage-backfill-prepare';
|
|
1
3
|
import { authSetupCmd } from './auth-setup';
|
|
4
|
+
import { coveragePrepareCmd } from './coverage-prepare';
|
|
2
5
|
import { disputesCmd } from './disputes';
|
|
3
6
|
import { doctorCmd } from './doctor';
|
|
4
7
|
import { evalDeterministicCmd } from './eval-deterministic';
|
|
@@ -6,6 +9,8 @@ import { evalPrepareCmd } from './eval-prepare';
|
|
|
6
9
|
import { evalReportCmd } from './eval-report';
|
|
7
10
|
import { execCmd } from './exec';
|
|
8
11
|
import { fetchCmd } from './fetch';
|
|
12
|
+
import { fillGapFinalizeCmd } from './fill-gap-finalize';
|
|
13
|
+
import { fillGapPrepareCmd } from './fill-gap-prepare';
|
|
9
14
|
import { graphBackfillCmd } from './graph-backfill';
|
|
10
15
|
import { graphEnrichCmd } from './graph-enrich';
|
|
11
16
|
import { graphQueryCmd } from './graph-query';
|
|
@@ -26,13 +31,18 @@ import { validateFeatureCmd } from './validate-feature';
|
|
|
26
31
|
import { verifyPromptsCmd } from './verify-prompts';
|
|
27
32
|
|
|
28
33
|
const COMMANDS: Record<string, (argv: string[]) => Promise<number>> = {
|
|
34
|
+
'ac-coverage-backfill-finalize': acCoverageBackfillFinalizeCmd,
|
|
35
|
+
'ac-coverage-backfill-prepare': acCoverageBackfillPrepareCmd,
|
|
29
36
|
'auth-setup': authSetupCmd,
|
|
37
|
+
'coverage-prepare': coveragePrepareCmd,
|
|
30
38
|
disputes: disputesCmd,
|
|
31
39
|
doctor: doctorCmd,
|
|
32
40
|
'eval-deterministic': evalDeterministicCmd,
|
|
33
41
|
'eval-prepare': evalPrepareCmd,
|
|
34
42
|
'eval-report': evalReportCmd,
|
|
35
43
|
exec: execCmd,
|
|
44
|
+
'fill-gap-finalize': fillGapFinalizeCmd,
|
|
45
|
+
'fill-gap-prepare': fillGapPrepareCmd,
|
|
36
46
|
fetch: fetchCmd,
|
|
37
47
|
'graph-backfill': graphBackfillCmd,
|
|
38
48
|
'graph-enrich': graphEnrichCmd,
|
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
|
+
}
|