@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.
Files changed (34) hide show
  1. package/dist/bin/internal.js +1415 -534
  2. package/dist/bin/templates/LICENSE-vis-network.txt +2 -0
  3. package/dist/bin/templates/coverage-panel.html.fragment +20 -0
  4. package/dist/bin/templates/graph.css +379 -43
  5. package/dist/bin/templates/graph.html.template +35 -15
  6. package/dist/bin/templates/graph.js +458 -56
  7. package/dist/bin/templates/vis-network.min.js +3 -24976
  8. package/dist/src/index.js +6 -0
  9. package/package.json +3 -3
  10. package/src/bin-internal/ac-coverage-backfill-finalize.ts +90 -0
  11. package/src/bin-internal/ac-coverage-backfill-prepare.ts +72 -0
  12. package/src/bin-internal/coverage-prepare.ts +123 -0
  13. package/src/bin-internal/fill-gap-finalize.ts +115 -0
  14. package/src/bin-internal/fill-gap-prepare.ts +150 -0
  15. package/src/bin-internal/graph-render.ts +32 -4
  16. package/src/bin-internal/index.ts +10 -0
  17. package/src/bin-internal/verify-prompts.ts +2 -0
  18. package/src/config/schema.ts +9 -0
  19. package/src/coverage/index.ts +29 -0
  20. package/src/coverage/report.ts +206 -0
  21. package/src/coverage/risk.ts +69 -0
  22. package/src/coverage/status.ts +76 -0
  23. package/src/coverage/types.ts +11 -0
  24. package/src/coverage/why.ts +122 -0
  25. package/src/graph/render.ts +16 -2
  26. package/src/graph/schema.ts +54 -1
  27. package/src/graph/store.ts +96 -6
  28. package/src/graph/templates/LICENSE-vis-network.txt +2 -0
  29. package/src/graph/templates/coverage-panel.html.fragment +20 -0
  30. package/src/graph/templates/graph.css +379 -43
  31. package/src/graph/templates/graph.html.template +35 -15
  32. package/src/graph/templates/graph.js +458 -56
  33. package/src/graph/templates/vis-network.min.js +3 -24976
  34. package/src/graph/types.ts +56 -1
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.7",
3
+ "version": "0.10.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.7",
35
- "@xera-ai/http": "^0.9.7",
34
+ "@xera-ai/web": "^0.10.0",
35
+ "@xera-ai/http": "^0.10.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
+ }
@@ -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,