@xera-ai/core 0.9.8 → 0.11.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,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
+ }
@@ -11,6 +11,10 @@
11
11
  <div class="logo"></div>
12
12
  <span class="title">xera graph</span>
13
13
  </div>
14
+ <nav class="toplevel-tabs">
15
+ <button data-tab="knowledge" class="active">Knowledge</button>
16
+ {{COVERAGE_TAB_BUTTON}}
17
+ </nav>
14
18
  <div id="stats-bar" data-stats="{{STATS}}"></div>
15
19
  <div class="topbar-controls">
16
20
  <input type="text" id="search" placeholder="search nodes…" autocomplete="off" />
@@ -23,21 +27,25 @@
23
27
  </div>
24
28
  </header>
25
29
  <div id="progress-wrap"><div id="progress-bar"></div></div>
26
- <main id="canvas"></main>
27
- <aside id="sidepanel" class="hidden">
28
- <div class="sp-header">
29
- <div id="sp-group" class="sp-group-badge"></div>
30
- <p id="sp-title" class="sp-title"></p>
31
- </div>
32
- <div id="sp-desc" class="sp-desc"></div>
33
- <div id="sp-actions" class="sp-actions"></div>
34
- </aside>
30
+ <section data-tab-panel="knowledge" class="active">
31
+ <main id="canvas"></main>
32
+ <aside id="sidepanel" class="hidden">
33
+ <div class="sp-header">
34
+ <div id="sp-group" class="sp-group-badge"></div>
35
+ <p id="sp-title" class="sp-title"></p>
36
+ </div>
37
+ <div id="sp-desc" class="sp-desc"></div>
38
+ <div id="sp-actions" class="sp-actions"></div>
39
+ </aside>
40
+ </section>
41
+ {{COVERAGE_TAB_PANEL}}
35
42
  <footer id="footer">
36
43
  generated {{GENERATED_AT}}
37
44
  <span>· scroll to zoom · drag to pan · click to inspect</span>
38
45
  </footer>
39
46
  <script>{{VIS_NETWORK_JS}}</script>
40
47
  <script>window.__GRAPH__ = {{GRAPH_DATA}};</script>
48
+ <script>window.__COVERAGE__ = {{COVERAGE_DATA}};</script>
41
49
  <script>{{INTERACTION_JS}}</script>
42
50
  </body>
43
51
  </html>
@@ -316,3 +316,188 @@
316
316
  };
317
317
  });
318
318
  })();
319
+
320
+ // v0.8.1 — top-level tab switching
321
+ (function setupTabs() {
322
+ const tabButtons = document.querySelectorAll('.toplevel-tabs button');
323
+ if (!tabButtons.length) return;
324
+ tabButtons.forEach((btn) => {
325
+ btn.addEventListener('click', () => {
326
+ tabButtons.forEach((b) => {
327
+ b.classList.remove('active');
328
+ });
329
+ btn.classList.add('active');
330
+ const tab = btn.getAttribute('data-tab');
331
+ document.querySelectorAll('[data-tab-panel]').forEach((panel) => {
332
+ if (panel.getAttribute('data-tab-panel') === tab) {
333
+ panel.classList.add('active');
334
+ panel.removeAttribute('hidden');
335
+ } else {
336
+ panel.classList.remove('active');
337
+ }
338
+ });
339
+ if (tab === 'coverage' && window.__COVERAGE__) {
340
+ renderCoverageOnce();
341
+ }
342
+ });
343
+ });
344
+ })();
345
+
346
+ // v0.8.1 — coverage subtab switching
347
+ (function setupSubtabs() {
348
+ const subButtons = document.querySelectorAll('.subtabs button');
349
+ subButtons.forEach((btn) => {
350
+ btn.addEventListener('click', () => {
351
+ subButtons.forEach((b) => {
352
+ b.classList.remove('active');
353
+ });
354
+ btn.classList.add('active');
355
+ const sub = btn.getAttribute('data-subtab');
356
+ document.querySelectorAll('[data-subpanel]').forEach((panel) => {
357
+ if (panel.getAttribute('data-subpanel') === sub) {
358
+ panel.removeAttribute('hidden');
359
+ panel.classList.add('active');
360
+ } else {
361
+ panel.setAttribute('hidden', '');
362
+ panel.classList.remove('active');
363
+ }
364
+ });
365
+ });
366
+ });
367
+ })();
368
+
369
+ let _coverageRendered = false;
370
+ function renderCoverageOnce() {
371
+ if (_coverageRendered) return;
372
+ _coverageRendered = true;
373
+ renderCoverageList();
374
+ renderCoverageTrend();
375
+ renderCoverageMap();
376
+ }
377
+
378
+ // Task 27 — coverage map: area color overlay
379
+ function renderCoverageMap() {
380
+ const cov = window.__COVERAGE__;
381
+ if (!cov || !window.__GRAPH__) return;
382
+ const canvas = document.getElementById('coverage-map-canvas');
383
+ if (!canvas) return;
384
+
385
+ const STATUS_COLOR = {
386
+ UNCOVERED: { background: '#fca5a5', border: '#dc2626' },
387
+ STALE: { background: '#fcd34d', border: '#d97706' },
388
+ COVERED: { background: '#86efac', border: '#15803d' },
389
+ };
390
+ const NEUTRAL = { background: '#e5e7eb', border: '#9ca3af' };
391
+
392
+ const areaStatusById = {};
393
+ for (const a of cov.report.areas) {
394
+ areaStatusById[a.id] = a.status;
395
+ }
396
+
397
+ const mappedNodes = window.__GRAPH__.nodes.map((n) => {
398
+ if (n.group === 'SUTArea' && areaStatusById[n.id]) {
399
+ return Object.assign({}, n, { color: STATUS_COLOR[areaStatusById[n.id]] });
400
+ }
401
+ if (n.group !== 'SUTArea') return Object.assign({}, n, { color: NEUTRAL });
402
+ return n;
403
+ });
404
+
405
+ new vis.Network(
406
+ canvas,
407
+ { nodes: new vis.DataSet(mappedNodes), edges: new vis.DataSet(window.__GRAPH__.edges) },
408
+ {
409
+ physics: { enabled: true, stabilization: { iterations: 100 } },
410
+ nodes: { shape: 'dot', font: { size: 11 } },
411
+ },
412
+ );
413
+ }
414
+
415
+ // Task 28 — coverage list: sortable area + AC gap tables
416
+ function renderCoverageList() {
417
+ const cov = window.__COVERAGE__;
418
+ if (!cov) return;
419
+ const listBody = document.querySelector('#coverage-list-table tbody');
420
+ if (listBody) {
421
+ listBody.innerHTML = '';
422
+ for (const a of cov.report.areas) {
423
+ const tr = document.createElement('tr');
424
+ tr.classList.add(`status-${a.status.toLowerCase()}`);
425
+ const cells = [
426
+ a.status,
427
+ a.id,
428
+ String(a.risk),
429
+ String(a.breakdown.recentTickets),
430
+ String(a.breakdown.recentBugs),
431
+ ];
432
+ for (const c of cells) {
433
+ const td = document.createElement('td');
434
+ td.textContent = c;
435
+ tr.appendChild(td);
436
+ }
437
+ listBody.appendChild(tr);
438
+ }
439
+ }
440
+
441
+ const acBody = document.querySelector('#coverage-ac-table tbody');
442
+ if (acBody) {
443
+ acBody.innerHTML = '';
444
+ for (const t of cov.report.tickets) {
445
+ const tr = document.createElement('tr');
446
+ const cells = [
447
+ t.id,
448
+ `${t.satisfiedCount}/${t.acCount}`,
449
+ String(t.gapScore),
450
+ t.unsatisfiedAcs.map((ac) => `AC-${ac.index}`).join(', '),
451
+ ];
452
+ for (const c of cells) {
453
+ const td = document.createElement('td');
454
+ td.textContent = c;
455
+ tr.appendChild(td);
456
+ }
457
+ acBody.appendChild(tr);
458
+ }
459
+ }
460
+ }
461
+
462
+ // Task 29 — coverage trend: inline SVG line chart
463
+ function renderCoverageTrend() {
464
+ const cov = window.__COVERAGE__;
465
+ if (!cov) return;
466
+ const container = document.getElementById('coverage-trend-svg');
467
+ if (!container) return;
468
+
469
+ // Dedup by day (latest snapshot per day wins), sort asc.
470
+ const byDay = {};
471
+ for (const s of cov.snapshots) {
472
+ const day = s.ts.slice(0, 10);
473
+ byDay[day] = s;
474
+ }
475
+ const days = Object.keys(byDay).sort();
476
+ if (days.length === 0) {
477
+ container.innerHTML =
478
+ '<p class="subpanel-hint">No snapshots yet — run /xera-coverage on multiple days to build a trend.</p>';
479
+ return;
480
+ }
481
+
482
+ const points = days.map((d) => {
483
+ const snap = byDay[d];
484
+ const n = snap.areas.filter((a) => a.status === 'UNCOVERED' || a.status === 'STALE').length;
485
+ return { day: d, value: n };
486
+ });
487
+ const W = 800;
488
+ const H = 200;
489
+ const PAD = 30;
490
+ const maxValue = Math.max(...points.map((p) => p.value), 1);
491
+ const stepX = points.length > 1 ? (W - 2 * PAD) / (points.length - 1) : 0;
492
+ const path = points
493
+ .map((p, idx) => {
494
+ const x = PAD + idx * stepX;
495
+ const y = H - PAD - (p.value / maxValue) * (H - 2 * PAD);
496
+ return `${idx === 0 ? 'M' : 'L'}${x},${y}`;
497
+ })
498
+ .join(' ');
499
+
500
+ const labelFirst = points[0].day;
501
+ const labelLast = points[points.length - 1].day;
502
+ container.innerHTML = `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg"><path d="${path}" fill="none" stroke="#dc2626" stroke-width="2"/><text x="${PAD}" y="${H - 8}" font-size="11" fill="#6b7280">${labelFirst}</text><text x="${W - PAD - 60}" y="${H - 8}" font-size="11" fill="#6b7280">${labelLast}</text><text x="${PAD - 22}" y="${PAD - 4}" font-size="11" fill="#6b7280">${maxValue}</text></svg>`;
503
+ }
package/dist/src/index.js CHANGED
@@ -396,6 +396,11 @@ var RunSchema = z4.object({
396
396
  threshold: z4.number().nonnegative().default(8)
397
397
  }).prefault({})
398
398
  }).prefault({});
399
+ var CoverageSchema = z4.object({
400
+ staleAfterDays: z4.number().int().positive().default(30),
401
+ criticalAreas: z4.array(z4.string().regex(/^[a-z0-9-]+$/)).default([]),
402
+ autoSnapshotOnCoverage: z4.boolean().default(true)
403
+ }).prefault({});
399
404
  var XeraConfigSchema = z4.object({
400
405
  jira: JiraSchema,
401
406
  web: WebSchema.optional(),
@@ -403,6 +408,7 @@ var XeraConfigSchema = z4.object({
403
408
  ai: AISchema,
404
409
  reporting: ReportingSchema,
405
410
  run: RunSchema.prefault({}),
411
+ coverage: CoverageSchema,
406
412
  adapters: z4.array(z4.enum(["web", "http"])).min(1).default(["web"])
407
413
  }).refine((c) => c.web !== undefined || c.http !== undefined, {
408
414
  message: "At least one of `web` or `http` must be configured"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xera-ai/core",
3
- "version": "0.9.8",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -31,8 +31,8 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "zod": "4.4.3",
34
- "@xera-ai/web": "^0.9.8",
35
- "@xera-ai/http": "^0.9.8",
34
+ "@xera-ai/web": "^0.11.0",
35
+ "@xera-ai/http": "^0.11.0",
36
36
  "@playwright/test": "1.60.0",
37
37
  "dotenv": "^16.0.0",
38
38
  "fflate": "0.8.3",
@@ -0,0 +1,90 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { z } from 'zod';
4
+ import { appendEvents } from '../graph/store';
5
+ import type { Event } from '../graph/types';
6
+ import { SCHEMA_VERSION } from '../graph/types';
7
+ import { ulid } from '../graph/ulid';
8
+
9
+ const DecisionsSchema = z.object({
10
+ mappings: z.array(
11
+ z.object({
12
+ scenarioId: z.string().min(1),
13
+ satisfiesAcs: z.array(z.number().int().nonnegative()),
14
+ confidence: z.number().min(0).max(1),
15
+ }),
16
+ ),
17
+ });
18
+
19
+ interface ParsedArgs {
20
+ inputFile?: string;
21
+ snapshotTs?: string;
22
+ }
23
+
24
+ function parseArgs(argv: string[]): ParsedArgs {
25
+ const args: ParsedArgs = {};
26
+ for (let i = 0; i < argv.length; i++) {
27
+ const a = argv[i];
28
+ if (a === '--input') {
29
+ const v = argv[++i];
30
+ if (v !== undefined) args.inputFile = v;
31
+ } else if (a === '--snapshot-ts') {
32
+ const v = argv[++i];
33
+ if (v !== undefined) args.snapshotTs = v;
34
+ } else if (a === '--help-stub') {
35
+ /* no-op */
36
+ } else {
37
+ console.error(`[ac-coverage-backfill-finalize] unknown flag: ${a}`);
38
+ return args;
39
+ }
40
+ }
41
+ return args;
42
+ }
43
+
44
+ export async function acCoverageBackfillFinalizeCmd(argv: string[]): Promise<number> {
45
+ const args = parseArgs(argv);
46
+ const cwd = process.cwd();
47
+ const inputPath = args.inputFile ?? join(cwd, '.xera/coverage/ac-backfill-decisions.json');
48
+
49
+ if (!existsSync(inputPath)) {
50
+ console.error(`[ac-coverage-backfill-finalize] decisions file not found: ${inputPath}`);
51
+ return 2;
52
+ }
53
+
54
+ let parsed: z.infer<typeof DecisionsSchema>;
55
+ try {
56
+ const raw = JSON.parse(readFileSync(inputPath, 'utf8'));
57
+ parsed = DecisionsSchema.parse(raw);
58
+ } catch (e) {
59
+ console.error(`[ac-coverage-backfill-finalize] invalid decisions: ${(e as Error).message}`);
60
+ return 2;
61
+ }
62
+
63
+ if (parsed.mappings.length === 0) return 0;
64
+
65
+ // Group mappings by ticketId (extracted from scenarioId prefix)
66
+ const byTicket: Record<string, z.infer<typeof DecisionsSchema>['mappings']> = {};
67
+ for (const m of parsed.mappings) {
68
+ const ticketId = m.scenarioId.split('#')[0];
69
+ if (!ticketId) continue;
70
+ if (!byTicket[ticketId]) byTicket[ticketId] = [];
71
+ byTicket[ticketId].push(m);
72
+ }
73
+
74
+ const ts = args.snapshotTs ?? new Date().toISOString();
75
+ const now = new Date(ts);
76
+
77
+ for (const [ticketId, mappings] of Object.entries(byTicket)) {
78
+ const event: Event = {
79
+ event_id: ulid(),
80
+ schema_version: SCHEMA_VERSION,
81
+ ts,
82
+ actor: 'xera-coverage',
83
+ type: 'ac-coverage.backfilled',
84
+ payload: { ts, ticketId, mappings },
85
+ };
86
+ appendEvents(cwd, [event], { skill: 'ac-coverage', ticketId, now });
87
+ }
88
+
89
+ return 0;
90
+ }
@@ -0,0 +1,72 @@
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 BackfillInput {
7
+ tickets: Array<{
8
+ id: string;
9
+ summary: string;
10
+ acs: string[];
11
+ scenarios: Array<{ id: string; name: string; gherkin: string }>;
12
+ }>;
13
+ }
14
+
15
+ function findUnmapped(snap: Snapshot): BackfillInput {
16
+ const out: BackfillInput['tickets'] = [];
17
+ for (const ticket of Object.values(snap.tickets)) {
18
+ if (ticket.ac.length === 0) continue;
19
+ const ticketScenarios = Object.values(snap.scenarios).filter((s) => s.ticketId === ticket.id);
20
+ if (ticketScenarios.length === 0) continue;
21
+ const acsForTicket = Object.values(snap.acNodes).filter((ac) => ac.ticketId === ticket.id);
22
+ const hasAnyEdge = snap.edges.some(
23
+ (e) => e.kind === 'satisfies' && acsForTicket.some((ac) => ac.id === e.to),
24
+ );
25
+ if (hasAnyEdge) continue;
26
+ out.push({
27
+ id: ticket.id,
28
+ summary: ticket.summary,
29
+ acs: ticket.ac,
30
+ scenarios: ticketScenarios.map((s) => ({
31
+ id: s.id,
32
+ name: s.name,
33
+ gherkin: s.gherkin,
34
+ })),
35
+ });
36
+ }
37
+ return { tickets: out };
38
+ }
39
+
40
+ interface ParsedArgs {
41
+ outputFile?: string;
42
+ }
43
+
44
+ function parseArgs(argv: string[]): ParsedArgs {
45
+ const args: ParsedArgs = {};
46
+ for (let i = 0; i < argv.length; i++) {
47
+ const a = argv[i];
48
+ if (a === '--output') {
49
+ const v = argv[++i];
50
+ if (v !== undefined) args.outputFile = v;
51
+ } else if (a === '--help-stub') {
52
+ /* no-op */
53
+ } else {
54
+ console.error(`[ac-coverage-backfill-prepare] unknown flag: ${a}`);
55
+ return args;
56
+ }
57
+ }
58
+ return args;
59
+ }
60
+
61
+ export async function acCoverageBackfillPrepareCmd(argv: string[]): Promise<number> {
62
+ const args = parseArgs(argv);
63
+ const cwd = process.cwd();
64
+ const snap = deriveSnapshot(loadAllEvents(cwd));
65
+ const input = findUnmapped(snap);
66
+
67
+ const outDir = join(cwd, '.xera/coverage');
68
+ mkdirSync(outDir, { recursive: true });
69
+ const outPath = args.outputFile ?? join(outDir, 'ac-backfill-input.json');
70
+ writeFileSync(outPath, JSON.stringify(input, null, 2));
71
+ return 0;
72
+ }
@@ -0,0 +1,123 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { loadConfig } from '../config/load';
4
+ import type { XeraConfig } from '../config/schema';
5
+ import {
6
+ buildCoverageReport,
7
+ buildWhyArea,
8
+ buildWhyTicket,
9
+ type RenderOptions,
10
+ renderMarkdown,
11
+ } from '../coverage';
12
+ import { appendEvents, deriveSnapshot, loadAllEvents } from '../graph/store';
13
+ import type { Event, Snapshot } from '../graph/types';
14
+ import { ulid } from '../graph/ulid';
15
+
16
+ interface ParsedArgs {
17
+ snapshotTs?: string;
18
+ emitEvent: boolean;
19
+ why?: string;
20
+ json: boolean;
21
+ all: boolean;
22
+ snapshotFile?: string;
23
+ }
24
+
25
+ function parseArgs(argv: string[]): ParsedArgs {
26
+ const args: ParsedArgs = { emitEvent: true, json: false, all: false };
27
+ for (let i = 0; i < argv.length; i++) {
28
+ const a = argv[i];
29
+ if (a === '--snapshot-ts') {
30
+ const v = argv[++i];
31
+ if (v !== undefined) args.snapshotTs = v;
32
+ } else if (a === '--no-emit-event') args.emitEvent = false;
33
+ else if (a === '--why') {
34
+ const v = argv[++i];
35
+ if (v !== undefined) args.why = v;
36
+ } else if (a === '--json') args.json = true;
37
+ else if (a === '--all') args.all = true;
38
+ else if (a === '--snapshot-file') {
39
+ const v = argv[++i];
40
+ if (v !== undefined) args.snapshotFile = v;
41
+ } else if (a === '--help-stub') {
42
+ /* no-op for test scaffold */
43
+ } else {
44
+ console.error(`[coverage-prepare] unknown flag: ${a}`);
45
+ return { ...args, emitEvent: false };
46
+ }
47
+ }
48
+ return args;
49
+ }
50
+
51
+ const TICKET_RE = /^[A-Z][A-Z0-9]*-\d+$/;
52
+
53
+ export async function coveragePrepareCmd(argv: string[]): Promise<number> {
54
+ const args = parseArgs(argv);
55
+
56
+ const cwd = process.cwd();
57
+ let config: XeraConfig;
58
+ try {
59
+ config = await loadConfig(cwd);
60
+ } catch (e) {
61
+ console.error(`[coverage-prepare] ${(e as Error).message}`);
62
+ return 2;
63
+ }
64
+
65
+ let snap: Snapshot;
66
+ if (args.snapshotFile) {
67
+ snap = JSON.parse(readFileSync(args.snapshotFile, 'utf8')) as Snapshot;
68
+ } else {
69
+ snap = deriveSnapshot(loadAllEvents(cwd));
70
+ }
71
+
72
+ const now = args.snapshotTs ? new Date(args.snapshotTs) : new Date();
73
+
74
+ if (args.why) {
75
+ const out = TICKET_RE.test(args.why)
76
+ ? buildWhyTicket(args.why, snap, config.coverage, now)
77
+ : buildWhyArea(args.why, snap, config.coverage, now);
78
+ process.stdout.write(out);
79
+ return 0;
80
+ }
81
+
82
+ const report = buildCoverageReport(snap, config.coverage, now);
83
+
84
+ if (args.json) {
85
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
86
+ return 0;
87
+ }
88
+
89
+ const outDir = join(cwd, '.xera/coverage');
90
+ mkdirSync(outDir, { recursive: true });
91
+ writeFileSync(join(outDir, 'report.json'), JSON.stringify(report, null, 2));
92
+ const renderOpts: RenderOptions = { includeCovered: args.all };
93
+ writeFileSync(join(outDir, 'report.md'), renderMarkdown(report, renderOpts));
94
+
95
+ if (args.emitEvent && config.coverage.autoSnapshotOnCoverage) {
96
+ const event: Event = {
97
+ event_id: ulid(),
98
+ schema_version: 1,
99
+ ts: now.toISOString(),
100
+ actor: 'xera-coverage',
101
+ type: 'coverage.snapshot',
102
+ payload: {
103
+ ts: now.toISOString(),
104
+ windowDays: config.coverage.staleAfterDays,
105
+ areas: report.areas.map((a) => ({
106
+ id: a.id,
107
+ status: a.status,
108
+ risk: a.risk,
109
+ breakdown: a.breakdown,
110
+ })),
111
+ tickets: report.tickets.map((t) => ({
112
+ id: t.id,
113
+ acCount: t.acCount,
114
+ satisfiedCount: t.satisfiedCount,
115
+ gapScore: t.gapScore,
116
+ })),
117
+ },
118
+ };
119
+ appendEvents(cwd, [event], { skill: 'coverage', ticketId: 'session', now });
120
+ }
121
+
122
+ return 0;
123
+ }