@xera-ai/core 0.9.8 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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 snap = deriveSnapshot(loadAllEvents(repoRoot));
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 html = renderHtml({ data, stats: data.stats, generatedAt: new Date().toISOString() });
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,
@@ -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
+ }