@xera-ai/core 0.1.7 → 0.4.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 (131) hide show
  1. package/bin/internal.ts +1 -0
  2. package/dist/adapter/types.d.ts +1 -1
  3. package/dist/adapter/types.d.ts.map +1 -1
  4. package/dist/artifact/meta.d.ts +2 -28
  5. package/dist/artifact/meta.d.ts.map +1 -1
  6. package/dist/artifact/status.d.ts +49 -74
  7. package/dist/artifact/status.d.ts.map +1 -1
  8. package/dist/auth/key.d.ts.map +1 -1
  9. package/dist/auth/refresh.d.ts.map +1 -1
  10. package/dist/auth/state.d.ts +5 -14
  11. package/dist/auth/state.d.ts.map +1 -1
  12. package/dist/bin/internal.js +10037 -746
  13. package/dist/bin-internal/doctor.d.ts +5 -0
  14. package/dist/bin-internal/doctor.d.ts.map +1 -0
  15. package/dist/bin-internal/eval-deterministic.d.ts +5 -0
  16. package/dist/bin-internal/eval-deterministic.d.ts.map +1 -0
  17. package/dist/bin-internal/eval-prepare.d.ts +7 -0
  18. package/dist/bin-internal/eval-prepare.d.ts.map +1 -0
  19. package/dist/bin-internal/eval-report.d.ts +5 -0
  20. package/dist/bin-internal/eval-report.d.ts.map +1 -0
  21. package/dist/bin-internal/exec.d.ts.map +1 -1
  22. package/dist/bin-internal/fetch.d.ts.map +1 -1
  23. package/dist/bin-internal/graph-backfill.d.ts +2 -0
  24. package/dist/bin-internal/graph-backfill.d.ts.map +1 -0
  25. package/dist/bin-internal/graph-query.d.ts +2 -0
  26. package/dist/bin-internal/graph-query.d.ts.map +1 -0
  27. package/dist/bin-internal/graph-record-script.d.ts +2 -0
  28. package/dist/bin-internal/graph-record-script.d.ts.map +1 -0
  29. package/dist/bin-internal/graph-record.d.ts +3 -0
  30. package/dist/bin-internal/graph-record.d.ts.map +1 -0
  31. package/dist/bin-internal/graph-snapshot.d.ts +2 -0
  32. package/dist/bin-internal/graph-snapshot.d.ts.map +1 -0
  33. package/dist/bin-internal/heal-prepare.d.ts +19 -0
  34. package/dist/bin-internal/heal-prepare.d.ts.map +1 -0
  35. package/dist/bin-internal/index.d.ts.map +1 -1
  36. package/dist/bin-internal/lint.d.ts.map +1 -1
  37. package/dist/bin-internal/normalize.d.ts.map +1 -1
  38. package/dist/bin-internal/post.d.ts.map +1 -1
  39. package/dist/bin-internal/status-cmd.d.ts.map +1 -1
  40. package/dist/bin-internal/typecheck.d.ts.map +1 -1
  41. package/dist/bin-internal/unlock.d.ts.map +1 -1
  42. package/dist/bin-internal/validate-feature.d.ts.map +1 -1
  43. package/dist/bin-internal/verify-prompts.d.ts +7 -0
  44. package/dist/bin-internal/verify-prompts.d.ts.map +1 -0
  45. package/dist/classifier/aggregate.d.ts.map +1 -1
  46. package/dist/config/define.d.ts.map +1 -1
  47. package/dist/config/load.d.ts.map +1 -1
  48. package/dist/config/schema.d.ts +38 -298
  49. package/dist/config/schema.d.ts.map +1 -1
  50. package/dist/eval/paths.d.ts +15 -0
  51. package/dist/eval/paths.d.ts.map +1 -0
  52. package/dist/eval/run-id.d.ts +6 -0
  53. package/dist/eval/run-id.d.ts.map +1 -0
  54. package/dist/eval/types.d.ts +203 -0
  55. package/dist/eval/types.d.ts.map +1 -0
  56. package/dist/graph/cost.d.ts +21 -0
  57. package/dist/graph/cost.d.ts.map +1 -0
  58. package/dist/graph/index.d.ts +8 -0
  59. package/dist/graph/index.d.ts.map +1 -0
  60. package/dist/graph/paths.d.ts +10 -0
  61. package/dist/graph/paths.d.ts.map +1 -0
  62. package/dist/graph/schema.d.ts +177 -0
  63. package/dist/graph/schema.d.ts.map +1 -0
  64. package/dist/graph/store.d.ts +14 -0
  65. package/dist/graph/store.d.ts.map +1 -0
  66. package/dist/graph/types.d.ts +151 -0
  67. package/dist/graph/types.d.ts.map +1 -0
  68. package/dist/graph/ulid.d.ts +2 -0
  69. package/dist/graph/ulid.d.ts.map +1 -0
  70. package/dist/index.d.ts +11 -11
  71. package/dist/index.d.ts.map +1 -1
  72. package/dist/jira/client.d.ts.map +1 -1
  73. package/dist/jira/fields.d.ts.map +1 -1
  74. package/dist/jira/rest-backend.d.ts.map +1 -1
  75. package/dist/reporter/jira-comment.d.ts.map +1 -1
  76. package/dist/reporter/status-writer.d.ts.map +1 -1
  77. package/dist/src/index.js +349 -321
  78. package/package.json +19 -13
  79. package/src/adapter/types.ts +5 -2
  80. package/src/artifact/meta.ts +1 -1
  81. package/src/artifact/status.ts +1 -1
  82. package/src/auth/encrypt.ts +2 -2
  83. package/src/auth/key.ts +1 -2
  84. package/src/auth/refresh.ts +5 -1
  85. package/src/auth/state.ts +2 -2
  86. package/src/bin-internal/doctor.ts +169 -0
  87. package/src/bin-internal/eval-deterministic.ts +149 -0
  88. package/src/bin-internal/eval-prepare.ts +214 -0
  89. package/src/bin-internal/eval-report.ts +177 -0
  90. package/src/bin-internal/exec.ts +28 -15
  91. package/src/bin-internal/fetch.ts +21 -10
  92. package/src/bin-internal/graph-backfill.ts +43 -0
  93. package/src/bin-internal/graph-query.ts +43 -0
  94. package/src/bin-internal/graph-record-script.ts +191 -0
  95. package/src/bin-internal/graph-record.ts +243 -0
  96. package/src/bin-internal/graph-snapshot.ts +23 -0
  97. package/src/bin-internal/heal-prepare.ts +230 -0
  98. package/src/bin-internal/index.ts +33 -11
  99. package/src/bin-internal/lint.ts +11 -4
  100. package/src/bin-internal/normalize.ts +23 -9
  101. package/src/bin-internal/post.ts +10 -4
  102. package/src/bin-internal/report.ts +3 -3
  103. package/src/bin-internal/status-cmd.ts +11 -3
  104. package/src/bin-internal/typecheck.ts +9 -3
  105. package/src/bin-internal/unlock.ts +12 -4
  106. package/src/bin-internal/validate-feature.ts +14 -5
  107. package/src/bin-internal/verify-prompts.ts +60 -0
  108. package/src/classifier/aggregate.ts +13 -6
  109. package/src/config/define.ts +3 -1
  110. package/src/config/load.ts +1 -1
  111. package/src/config/schema.ts +43 -37
  112. package/src/eval/paths.ts +32 -0
  113. package/src/eval/run-id.ts +30 -0
  114. package/src/eval/types.ts +101 -0
  115. package/src/graph/cost.ts +59 -0
  116. package/src/graph/index.ts +15 -0
  117. package/src/graph/paths.ts +27 -0
  118. package/src/graph/schema.ts +135 -0
  119. package/src/graph/store.ts +231 -0
  120. package/src/graph/types.ts +174 -0
  121. package/src/graph/ulid.ts +58 -0
  122. package/src/index.ts +11 -11
  123. package/src/jira/client.ts +4 -2
  124. package/src/jira/fields.ts +4 -2
  125. package/src/jira/mcp-backend.ts +1 -1
  126. package/src/jira/rest-backend.ts +18 -6
  127. package/src/jira/retry.ts +2 -2
  128. package/src/lock/file-lock.ts +2 -2
  129. package/src/logging/ndjson-logger.ts +2 -2
  130. package/src/reporter/jira-comment.ts +13 -7
  131. package/src/reporter/status-writer.ts +2 -2
@@ -0,0 +1,243 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { basename, join } from 'node:path';
4
+ import { parse as parseYaml } from 'yaml';
5
+ import { appendEvents } from '../graph/store';
6
+ import type {
7
+ EdgeDiscoveredPayload,
8
+ Event,
9
+ PomPromotedPayload,
10
+ RunClassifiedPayload,
11
+ RunCompletedPayload,
12
+ TicketFetchedPayload,
13
+ } from '../graph/types';
14
+ import { SCHEMA_VERSION } from '../graph/types';
15
+ import { ulid } from '../graph/ulid';
16
+
17
+ function nowIso(): string {
18
+ return new Date().toISOString();
19
+ }
20
+ function sha1(s: string): string {
21
+ return createHash('sha1').update(s).digest('hex');
22
+ }
23
+ function scenarioId(ticket: string, name: string): string {
24
+ return sha1(`${ticket}:${name.trim().toLowerCase().replace(/\s+/g, ' ')}`);
25
+ }
26
+ function pomId(filePath: string): string {
27
+ return sha1(basename(filePath));
28
+ }
29
+ function makeEvent<T extends Event['type']>(
30
+ actor: string,
31
+ type: T,
32
+ payload: Extract<Event, { type: T }>['payload'],
33
+ ): Event {
34
+ return {
35
+ event_id: ulid(),
36
+ schema_version: SCHEMA_VERSION,
37
+ ts: nowIso(),
38
+ actor,
39
+ type,
40
+ payload,
41
+ } as Event;
42
+ }
43
+
44
+ interface StoryFrontmatter {
45
+ ticketId: string;
46
+ summary: string;
47
+ storyHash: string;
48
+ acceptanceCriteria?: string[];
49
+ linked_issues?: Array<{
50
+ ticketId: string;
51
+ relation: 'blocks' | 'duplicates' | 'relates' | 'supersedes';
52
+ }>;
53
+ }
54
+
55
+ function readStoryFrontmatter(repoRoot: string, ticket: string): StoryFrontmatter | null {
56
+ const path = join(repoRoot, '.xera', ticket, 'story.md');
57
+ if (!existsSync(path)) return null;
58
+ const raw = readFileSync(path, 'utf8');
59
+ const m = raw.match(/^---\n([\s\S]*?)\n---/);
60
+ if (!m) return null;
61
+ return parseYaml(m[1]!) as StoryFrontmatter;
62
+ }
63
+
64
+ function readGraphInput(repoRoot: string, ticket: string): { modifiesAreas: string[] } {
65
+ const path = join(repoRoot, '.xera', ticket, 'graph-input.json');
66
+ if (!existsSync(path)) return { modifiesAreas: [] };
67
+ try {
68
+ return JSON.parse(readFileSync(path, 'utf8'));
69
+ } catch {
70
+ return { modifiesAreas: [] };
71
+ }
72
+ }
73
+
74
+ export async function recordFetch(repoRoot: string, ticket: string): Promise<number> {
75
+ const fm = readStoryFrontmatter(repoRoot, ticket);
76
+ if (!fm) {
77
+ console.error(`[graph-record fetch] story.md not found for ${ticket}`);
78
+ return 1;
79
+ }
80
+ const { modifiesAreas } = readGraphInput(repoRoot, ticket);
81
+ const events: Event[] = [];
82
+ const fetchedPayload: TicketFetchedPayload = {
83
+ ticketId: fm.ticketId,
84
+ summary: fm.summary,
85
+ ac: fm.acceptanceCriteria ?? [],
86
+ jiraLinks: fm.linked_issues ?? [],
87
+ storyHash: fm.storyHash,
88
+ modifiesAreas,
89
+ };
90
+ events.push(makeEvent('xera-fetch', 'ticket.fetched', fetchedPayload));
91
+ for (const link of fm.linked_issues ?? []) {
92
+ const p: EdgeDiscoveredPayload = {
93
+ kind: 'jira-linked',
94
+ from: fm.ticketId,
95
+ to: link.ticketId,
96
+ source: `jira:${link.relation}`,
97
+ };
98
+ events.push(makeEvent('xera-fetch', 'edge.discovered', p));
99
+ }
100
+ for (const area of modifiesAreas) {
101
+ const p: EdgeDiscoveredPayload = {
102
+ kind: 'modifies',
103
+ from: fm.ticketId,
104
+ to: area,
105
+ source: 'extract-areas',
106
+ };
107
+ events.push(makeEvent('xera-fetch', 'edge.discovered', p));
108
+ }
109
+ appendEvents(repoRoot, events, { skill: 'xera-fetch', ticketId: ticket });
110
+ return 0;
111
+ }
112
+
113
+ async function recordScript(repoRoot: string, ticket: string): Promise<number> {
114
+ const { recordScriptImpl } = await import('./graph-record-script');
115
+ return recordScriptImpl(repoRoot, ticket);
116
+ }
117
+
118
+ async function recordExec(repoRoot: string, ticket: string, runId: string): Promise<number> {
119
+ const reporterPath = join(repoRoot, '.xera', ticket, 'runs', runId, 'reporter.json');
120
+ if (!existsSync(reporterPath)) {
121
+ console.error(`[graph-record exec] reporter.json missing`);
122
+ return 1;
123
+ }
124
+ const data = JSON.parse(readFileSync(reporterPath, 'utf8')) as {
125
+ scenarios: Array<{ name: string; status: 'pass' | 'fail'; runtime: number; traceId?: string }>;
126
+ };
127
+ const events: Event[] = [];
128
+ for (const s of data.scenarios) {
129
+ const p: RunCompletedPayload = {
130
+ scenarioId: scenarioId(ticket, s.name),
131
+ ticketId: ticket,
132
+ runId,
133
+ status: s.status,
134
+ runtime: s.runtime,
135
+ };
136
+ if (s.traceId) p.traceId = s.traceId;
137
+ events.push(makeEvent('xera-exec', 'run.completed', p));
138
+ }
139
+ appendEvents(repoRoot, events, { skill: 'xera-exec', ticketId: ticket });
140
+ return 0;
141
+ }
142
+
143
+ async function recordClassify(repoRoot: string, ticket: string, runId: string): Promise<number> {
144
+ const classifyPath = join(repoRoot, '.xera', ticket, 'runs', runId, 'classifier-output.json');
145
+ if (!existsSync(classifyPath)) {
146
+ console.error(`[graph-record classify] classifier-output.json missing`);
147
+ return 1;
148
+ }
149
+ const data = JSON.parse(readFileSync(classifyPath, 'utf8')) as {
150
+ scenarios: Array<{ name: string; class: string; confidence: 'low' | 'medium' | 'high' }>;
151
+ };
152
+ const events: Event[] = [];
153
+ for (const s of data.scenarios) {
154
+ const p: RunClassifiedPayload = {
155
+ scenarioId: scenarioId(ticket, s.name),
156
+ runId,
157
+ classification: s.class as RunClassifiedPayload['classification'],
158
+ confidence: s.confidence,
159
+ };
160
+ events.push(makeEvent('xera-report', 'run.classified', p));
161
+ }
162
+ appendEvents(repoRoot, events, { skill: 'xera-report', ticketId: ticket });
163
+ return 0;
164
+ }
165
+
166
+ async function recordPromote(repoRoot: string, args: Map<string, string>): Promise<number> {
167
+ const from = args.get('--from');
168
+ const to = args.get('--to');
169
+ const pomIdArg = args.get('--pom-id');
170
+ if (!from || !to) {
171
+ console.error(`[graph-record promote] --from and --to required`);
172
+ return 1;
173
+ }
174
+ const id = pomIdArg ?? pomId(from);
175
+ const p: PomPromotedPayload = { pomId: id, fromPath: from, toPath: to };
176
+ const e = makeEvent('xera-promote', 'pom.promoted', p);
177
+ appendEvents(repoRoot, [e], { skill: 'xera-promote', ticketId: 'shared' });
178
+ return 0;
179
+ }
180
+
181
+ function parseFlags(args: string[]): Map<string, string> {
182
+ const m = new Map<string, string>();
183
+ for (let i = 0; i < args.length; i++) {
184
+ if (args[i]!.startsWith('--')) {
185
+ m.set(args[i]!, args[i + 1] ?? '');
186
+ i++;
187
+ }
188
+ }
189
+ return m;
190
+ }
191
+
192
+ export async function graphRecordCmd(argv: string[]): Promise<number> {
193
+ const [action, ...rest] = argv;
194
+ if (!action) {
195
+ console.error(`Usage: xera-internal graph-record <fetch|script|exec|classify|promote> [args]`);
196
+ return 1;
197
+ }
198
+ const repoRoot = process.cwd();
199
+ switch (action) {
200
+ case 'fetch': {
201
+ const ticket = rest[0];
202
+ if (!ticket) {
203
+ console.error('ticket required');
204
+ return 1;
205
+ }
206
+ return recordFetch(repoRoot, ticket);
207
+ }
208
+ case 'script': {
209
+ const ticket = rest[0];
210
+ if (!ticket) {
211
+ console.error('ticket required');
212
+ return 1;
213
+ }
214
+ return recordScript(repoRoot, ticket);
215
+ }
216
+ case 'exec': {
217
+ const ticket = rest[0];
218
+ const flags = parseFlags(rest);
219
+ const runId = flags.get('--run-id');
220
+ if (!ticket || !runId) {
221
+ console.error('ticket + --run-id required');
222
+ return 1;
223
+ }
224
+ return recordExec(repoRoot, ticket, runId);
225
+ }
226
+ case 'classify': {
227
+ const ticket = rest[0];
228
+ const flags = parseFlags(rest);
229
+ const runId = flags.get('--run-id');
230
+ if (!ticket || !runId) {
231
+ console.error('ticket + --run-id required');
232
+ return 1;
233
+ }
234
+ return recordClassify(repoRoot, ticket, runId);
235
+ }
236
+ case 'promote': {
237
+ return recordPromote(repoRoot, parseFlags(rest));
238
+ }
239
+ default:
240
+ console.error(`Unknown action: ${action}`);
241
+ return 1;
242
+ }
243
+ }
@@ -0,0 +1,23 @@
1
+ import { deriveSnapshot, isSnapshotStale, loadAllEvents, writeSnapshot } from '../graph/store';
2
+
3
+ export async function graphSnapshotCmd(argv: string[]): Promise<number> {
4
+ const check = argv.includes('--check');
5
+ const noRebuild = argv.includes('--no-rebuild');
6
+ const repoRoot = process.cwd();
7
+ const stale = isSnapshotStale(repoRoot);
8
+ if (check) {
9
+ if (!stale) return 0;
10
+ if (noRebuild) {
11
+ console.error('[graph-snapshot] stale');
12
+ return 1;
13
+ }
14
+ // fall through to rebuild
15
+ }
16
+ const events = loadAllEvents(repoRoot);
17
+ const snap = deriveSnapshot(events);
18
+ writeSnapshot(repoRoot, snap);
19
+ if (check && stale) {
20
+ console.log(`[graph-snapshot] rebuilt (${events.length} events)`);
21
+ }
22
+ return 0;
23
+ }
@@ -0,0 +1,230 @@
1
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { scrubFreeText } from '@xera-ai/web';
4
+ import { unzipSync } from 'fflate';
5
+ import { resolveArtifactPaths } from '../artifact/paths';
6
+
7
+ export type FailedLocatorKind = 'role' | 'test-id' | 'css-class' | 'text' | 'label' | 'other';
8
+
9
+ export interface HealInput {
10
+ ticket: string;
11
+ runId: string;
12
+ scenarioName: string;
13
+ failedLocator: {
14
+ raw: string;
15
+ kind: FailedLocatorKind;
16
+ pomFile: string;
17
+ pomLine: number;
18
+ pomLineContent: string;
19
+ pomMethodName: string;
20
+ };
21
+ gherkinStep: string;
22
+ domSnapshotAtFailure: string;
23
+ }
24
+
25
+ interface ClassifierInput {
26
+ runId: string;
27
+ scenarios: Array<{
28
+ name: string;
29
+ outcome: string;
30
+ class: string;
31
+ confidence: string;
32
+ rationale: string;
33
+ }>;
34
+ }
35
+
36
+ interface NormalizedRunFile {
37
+ runId: string;
38
+ scenarios: Array<{
39
+ name: string;
40
+ outcome: 'PASS' | 'FAIL' | 'SKIPPED';
41
+ failure?: { errorMessage?: string };
42
+ }>;
43
+ }
44
+
45
+ const LOCATOR_LINE_RE = /^Locator:\s*(.+)$/m;
46
+
47
+ function classifyKind(raw: string): FailedLocatorKind {
48
+ if (/^getByRole\b/.test(raw)) return 'role';
49
+ if (/^getByTestId\b/.test(raw)) return 'test-id';
50
+ if (/^getByLabel\b/.test(raw)) return 'label';
51
+ if (/^getByText\b/.test(raw)) return 'text';
52
+ if (/^locator\(\s*['"`]\s*\.[A-Za-z_-]/.test(raw)) return 'css-class';
53
+ return 'other';
54
+ }
55
+
56
+ function extractDomSnapshot(tracePath: string): string {
57
+ if (!existsSync(tracePath)) return '';
58
+ const buf = readFileSync(tracePath);
59
+ const entries = unzipSync(buf);
60
+
61
+ // Strategy: parse the .trace JSONL event file to find the last frame-snapshot
62
+ // event, then extract the HTML resource it references. This gives us the DOM
63
+ // snapshot closest to the failure point rather than a lexicographic approximation.
64
+ // Falls back to last .html by lex sort if the .trace file is missing or unparseable.
65
+ const traceKey = Object.keys(entries).find((name) => name.endsWith('.trace'));
66
+ let chosenKey: string | null = null;
67
+
68
+ if (traceKey) {
69
+ const traceText = new TextDecoder().decode(entries[traceKey]!);
70
+ const lines = traceText.split('\n').filter(Boolean);
71
+ // Walk events in REVERSE order to find the most recent frame-snapshot.
72
+ for (let i = lines.length - 1; i >= 0; i--) {
73
+ try {
74
+ const evt = JSON.parse(lines[i]!);
75
+ const isSnapshot = evt.type === 'frame-snapshot' || evt.type === 'snapshot';
76
+ if (!isSnapshot) continue;
77
+ const snap = evt.snapshot ?? {};
78
+ // Try direct resource reference (older format).
79
+ const resourceName: unknown = snap.resourceName;
80
+ if (typeof resourceName === 'string') {
81
+ if (entries[resourceName]) {
82
+ chosenKey = resourceName;
83
+ break;
84
+ }
85
+ // Some traces store .html files under resources/ with the resourceName as the basename.
86
+ const guessed = `resources/${resourceName.replace(/^resources\//, '')}`;
87
+ if (entries[guessed]) {
88
+ chosenKey = guessed;
89
+ break;
90
+ }
91
+ }
92
+ // Try snapshot name → look for matching .html in resources/.
93
+ const snapshotName: unknown = snap.snapshotName;
94
+ if (typeof snapshotName === 'string') {
95
+ const directGuess = Object.keys(entries).find(
96
+ (k) => k.endsWith('.html') && k.includes(snapshotName),
97
+ );
98
+ if (directGuess) {
99
+ chosenKey = directGuess;
100
+ break;
101
+ }
102
+ }
103
+ } catch {
104
+ // Skip unparseable trace lines.
105
+ }
106
+ }
107
+ }
108
+
109
+ // Fallback: last .html by lexicographic sort (existing heuristic).
110
+ if (!chosenKey) {
111
+ const htmlKeys = Object.keys(entries)
112
+ .filter((name) => name.endsWith('.html'))
113
+ .sort();
114
+ chosenKey = htmlKeys[htmlKeys.length - 1] ?? null;
115
+ }
116
+
117
+ if (!chosenKey) return '';
118
+ const html = new TextDecoder().decode(entries[chosenKey]!);
119
+ return scrubFreeText(html);
120
+ }
121
+
122
+ function findPomLine(
123
+ ticketDir: string,
124
+ rawLocator: string,
125
+ ): { pomFile: string; pomLine: number; pomLineContent: string; pomMethodName: string } {
126
+ const pomDir = join(ticketDir, 'page-objects');
127
+ const candidates: string[] = [];
128
+ if (existsSync(pomDir)) {
129
+ for (const name of readdirSync(pomDir)) {
130
+ if (name.endsWith('.ts')) candidates.push(join(pomDir, name));
131
+ }
132
+ }
133
+ for (const file of candidates) {
134
+ const text = readFileSync(file, 'utf8');
135
+ const lines = text.split('\n');
136
+ for (let i = 0; i < lines.length; i++) {
137
+ const line = lines[i]!;
138
+ if (line.includes(rawLocator)) {
139
+ const methodMatch = /^\s*(\w+)\s*=/.exec(line);
140
+ return {
141
+ pomFile: file,
142
+ pomLine: i + 1,
143
+ pomLineContent: line,
144
+ pomMethodName: methodMatch?.[1] ?? '<anonymous>',
145
+ };
146
+ }
147
+ }
148
+ }
149
+ throw new Error(`POM line not found for locator: ${rawLocator}`);
150
+ }
151
+
152
+ function findGherkinStep(featureText: string, rawLocator: string): string {
153
+ // Best-effort: find the first step line that mentions a quoted string
154
+ // appearing in the locator (e.g. a button name). Falls back to the
155
+ // first When/Then line if no match.
156
+ const quoteMatch = /['"`]([^'"`]{2,})['"`]/.exec(rawLocator);
157
+ if (quoteMatch) {
158
+ const needle = quoteMatch[1]!;
159
+ for (const line of featureText.split('\n')) {
160
+ if (line.includes(needle) && /^\s*(When|Then|And|Given)\b/.test(line)) {
161
+ return line.trim();
162
+ }
163
+ }
164
+ }
165
+ for (const line of featureText.split('\n')) {
166
+ if (/^\s*(When|Then)\b/.test(line)) return line.trim();
167
+ }
168
+ return '';
169
+ }
170
+
171
+ export function healPrepare(
172
+ repoRoot: string,
173
+ ticket: string,
174
+ runId: string,
175
+ scenarioName: string,
176
+ ): HealInput {
177
+ const paths = resolveArtifactPaths(repoRoot, ticket);
178
+ const classifierPath = join(paths.ticketDir, 'classifier-input.json');
179
+ const classifier: ClassifierInput = JSON.parse(readFileSync(classifierPath, 'utf8'));
180
+ const cls = classifier.scenarios.find((s) => s.name === scenarioName);
181
+ if (!cls) throw new Error(`scenario not found in classifier-input: "${scenarioName}"`);
182
+
183
+ const runDir = join(paths.runsDir, runId);
184
+ const normalized: NormalizedRunFile = JSON.parse(
185
+ readFileSync(join(runDir, 'normalized.json'), 'utf8'),
186
+ );
187
+ const normSc = normalized.scenarios.find((s) => s.name === scenarioName);
188
+ if (!normSc?.failure) throw new Error(`no failure recorded for scenario "${scenarioName}"`);
189
+ const errorMessage = normSc.failure.errorMessage ?? '';
190
+ const m = LOCATOR_LINE_RE.exec(errorMessage);
191
+ if (!m) throw new Error(`cannot extract locator from errorMessage: ${errorMessage.slice(0, 80)}`);
192
+ const raw = m[1]!.trim();
193
+ const kind = classifyKind(raw);
194
+
195
+ const pomLoc = findPomLine(paths.ticketDir, raw);
196
+
197
+ const featureText = readFileSync(paths.featurePath, 'utf8');
198
+ const gherkinStep = findGherkinStep(featureText, raw);
199
+
200
+ const domSnapshotAtFailure = extractDomSnapshot(join(runDir, 'trace.zip'));
201
+
202
+ return {
203
+ ticket,
204
+ runId,
205
+ scenarioName,
206
+ failedLocator: { raw, kind, ...pomLoc },
207
+ gherkinStep,
208
+ domSnapshotAtFailure,
209
+ };
210
+ }
211
+
212
+ export async function healPrepareCmd(argv: string[]): Promise<number> {
213
+ const [ticket, runId, ...scenarioParts] = argv;
214
+ if (!ticket || !runId || scenarioParts.length === 0) {
215
+ console.error('[xera:heal-prepare] usage: heal-prepare <TICKET> <RUN_ID> <SCENARIO_NAME>');
216
+ return 1;
217
+ }
218
+ const scenarioName = scenarioParts.join(' ');
219
+ try {
220
+ const result = healPrepare(process.cwd(), ticket, runId, scenarioName);
221
+ const paths = resolveArtifactPaths(process.cwd(), ticket);
222
+ const outPath = join(paths.runsDir, runId, 'heal-input.json');
223
+ writeFileSync(outPath, JSON.stringify(result, null, 2));
224
+ console.log(`[xera:heal-prepare] wrote ${outPath}`);
225
+ return 0;
226
+ } catch (err) {
227
+ console.error(`[xera:heal-prepare] ${(err as Error).message}`);
228
+ return 1;
229
+ }
230
+ }
@@ -1,33 +1,55 @@
1
+ import { doctorCmd } from './doctor';
2
+ import { evalDeterministicCmd } from './eval-deterministic';
3
+ import { evalPrepareCmd } from './eval-prepare';
4
+ import { evalReportCmd } from './eval-report';
5
+ import { execCmd } from './exec';
1
6
  import { fetchCmd } from './fetch';
2
- import { validateFeatureCmd } from './validate-feature';
3
- import { typecheckCmd } from './typecheck';
7
+ import { graphBackfillCmd } from './graph-backfill';
8
+ import { graphQueryCmd } from './graph-query';
9
+ import { graphRecordCmd } from './graph-record';
10
+ import { graphSnapshotCmd } from './graph-snapshot';
11
+ import { healPrepareCmd } from './heal-prepare';
4
12
  import { lintCmd } from './lint';
5
- import { execCmd } from './exec';
6
13
  import { normalizeCmd } from './normalize';
7
- import { reportCmd } from './report';
8
14
  import { postCmd } from './post';
15
+ import { promoteCmd } from './promote';
16
+ import { reportCmd } from './report';
9
17
  import { statusCmd } from './status-cmd';
18
+ import { typecheckCmd } from './typecheck';
10
19
  import { unlockCmd } from './unlock';
11
- import { promoteCmd } from './promote';
20
+ import { validateFeatureCmd } from './validate-feature';
21
+ import { verifyPromptsCmd } from './verify-prompts';
12
22
 
13
23
  const COMMANDS: Record<string, (argv: string[]) => Promise<number>> = {
24
+ doctor: doctorCmd,
25
+ 'eval-deterministic': evalDeterministicCmd,
26
+ 'eval-prepare': evalPrepareCmd,
27
+ 'eval-report': evalReportCmd,
28
+ exec: execCmd,
14
29
  fetch: fetchCmd,
15
- 'validate-feature': validateFeatureCmd,
16
- typecheck: typecheckCmd,
30
+ 'graph-backfill': graphBackfillCmd,
31
+ 'graph-query': graphQueryCmd,
32
+ 'graph-record': graphRecordCmd,
33
+ 'graph-snapshot': graphSnapshotCmd,
34
+ 'heal-prepare': healPrepareCmd,
17
35
  lint: lintCmd,
18
- exec: execCmd,
19
36
  normalize: normalizeCmd,
20
- report: reportCmd,
21
37
  post: postCmd,
38
+ promote: promoteCmd,
39
+ report: reportCmd,
22
40
  status: statusCmd,
41
+ typecheck: typecheckCmd,
23
42
  unlock: unlockCmd,
24
- promote: promoteCmd,
43
+ 'validate-feature': validateFeatureCmd,
44
+ 'verify-prompts': verifyPromptsCmd,
25
45
  };
26
46
 
27
47
  export async function run(argv: string[]): Promise<number> {
28
48
  const [cmd, ...rest] = argv;
29
49
  if (!cmd || !COMMANDS[cmd]) {
30
- console.error(`Usage: xera-internal <command> [args...]\nCommands: ${Object.keys(COMMANDS).join(', ')}`);
50
+ console.error(
51
+ `Usage: xera-internal <command> [args...]\nCommands: ${Object.keys(COMMANDS).join(', ')}`,
52
+ );
31
53
  return 1;
32
54
  }
33
55
  try {
@@ -1,12 +1,19 @@
1
- import { resolveArtifactPaths } from '../artifact/paths';
2
1
  import { lintTicket } from '@xera-ai/web';
2
+ import { resolveArtifactPaths } from '../artifact/paths';
3
3
 
4
4
  export async function lintCmd(argv: string[]): Promise<number> {
5
5
  const ticket = argv[0];
6
- if (!ticket) { console.error('[xera:lint] usage: lint <TICKET>'); return 1; }
6
+ if (!ticket) {
7
+ console.error('[xera:lint] usage: lint <TICKET>');
8
+ return 1;
9
+ }
7
10
  const paths = resolveArtifactPaths(process.cwd(), ticket);
8
11
  const r = await lintTicket(paths.ticketDir);
9
- if (r.ok) { console.log('[xera:lint] ok'); return 0; }
10
- for (const w of r.warnings) console.error(`[xera:lint] ${w.file}:${w.line} [${w.rule}] ${w.message}`);
12
+ if (r.ok) {
13
+ console.log('[xera:lint] ok');
14
+ return 0;
15
+ }
16
+ for (const w of r.warnings)
17
+ console.error(`[xera:lint] ${w.file}:${w.line} [${w.rule}] ${w.message}`);
11
18
  return 2;
12
19
  }
@@ -1,20 +1,34 @@
1
- import { resolveArtifactPaths } from '../artifact/paths';
2
- import { normalizeRun } from '@xera-ai/web';
3
- import { readdirSync, existsSync } from 'node:fs';
1
+ import { existsSync, readdirSync } from 'node:fs';
4
2
  import { join } from 'node:path';
3
+ import { normalizeRun } from '@xera-ai/web';
4
+ import { resolveArtifactPaths } from '../artifact/paths';
5
5
 
6
6
  export async function normalizeCmd(argv: string[]): Promise<number> {
7
7
  const ticket = argv[0];
8
- if (!ticket) { console.error('[xera:normalize] usage: normalize <TICKET> [--run=<runId>]'); return 1; }
8
+ if (!ticket) {
9
+ console.error('[xera:normalize] usage: normalize <TICKET> [--run=<runId>]');
10
+ return 1;
11
+ }
9
12
  const paths = resolveArtifactPaths(process.cwd(), ticket);
10
- const runArg = argv.find(a => a.startsWith('--run='));
13
+ const runArg = argv.find((a) => a.startsWith('--run='));
11
14
  const runId = runArg
12
15
  ? runArg.split('=')[1]!
13
- : readdirSync(paths.runsDir).filter(n => !n.startsWith('.')).sort().pop()!;
14
- if (!runId) { console.error('[xera:normalize] no run found'); return 1; }
16
+ : readdirSync(paths.runsDir)
17
+ .filter((n) => !n.startsWith('.'))
18
+ .sort()
19
+ .pop()!;
20
+ if (!runId) {
21
+ console.error('[xera:normalize] no run found');
22
+ return 1;
23
+ }
15
24
  const runDir = join(paths.runsDir, runId);
16
- if (!existsSync(runDir)) { console.error(`[xera:normalize] runs/${runId} missing`); return 1; }
25
+ if (!existsSync(runDir)) {
26
+ console.error(`[xera:normalize] runs/${runId} missing`);
27
+ return 1;
28
+ }
17
29
  const r = await normalizeRun({ runId, runDir });
18
- console.log(`[xera:normalize] wrote normalized.json (scrubbed_fields_count=${r.scrubbed_fields_count})`);
30
+ console.log(
31
+ `[xera:normalize] wrote normalized.json (scrubbed_fields_count=${r.scrubbed_fields_count})`,
32
+ );
19
33
  return 0;
20
34
  }
@@ -1,13 +1,16 @@
1
- import { readFileSync, existsSync } from 'node:fs';
1
+ import { existsSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { resolveArtifactPaths } from '../artifact/paths';
4
- import { loadConfig } from '../config/load';
5
4
  import { readStatus, writeStatus } from '../artifact/status';
5
+ import { loadConfig } from '../config/load';
6
6
  import { createJiraClient } from '../jira/client';
7
7
 
8
8
  export async function postCmd(argv: string[]): Promise<number> {
9
9
  const ticket = argv[0];
10
- if (!ticket) { console.error('[xera:post] usage: post <TICKET>'); return 1; }
10
+ if (!ticket) {
11
+ console.error('[xera:post] usage: post <TICKET>');
12
+ return 1;
13
+ }
11
14
  const cwd = process.cwd();
12
15
  const config = await loadConfig(cwd);
13
16
  if (!config.reporting.postToJira) {
@@ -16,7 +19,10 @@ export async function postCmd(argv: string[]): Promise<number> {
16
19
  }
17
20
  const paths = resolveArtifactPaths(cwd, ticket);
18
21
  const draftPath = join(paths.ticketDir, 'jira-comment.draft.md');
19
- if (!existsSync(draftPath)) { console.error(`[xera:post] no draft at ${draftPath}; run \`xera-internal report\` first.`); return 1; }
22
+ if (!existsSync(draftPath)) {
23
+ console.error(`[xera:post] no draft at ${draftPath}; run \`xera-internal report\` first.`);
24
+ return 1;
25
+ }
20
26
  const body = readFileSync(draftPath, 'utf8');
21
27
 
22
28
  const client = await createJiraClient({
@@ -2,9 +2,9 @@ import { readFileSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { resolveArtifactPaths } from '../artifact/paths';
4
4
  import { aggregateScenarios } from '../classifier/aggregate';
5
- import { writeStatusFromClassification } from '../reporter/status-writer';
6
- import { buildJiraComment } from '../reporter/jira-comment';
7
5
  import type { ScenarioClassification } from '../classifier/types';
6
+ import { buildJiraComment } from '../reporter/jira-comment';
7
+ import { writeStatusFromClassification } from '../reporter/status-writer';
8
8
 
9
9
  interface ReportInput {
10
10
  scenarios: ScenarioClassification[];
@@ -14,7 +14,7 @@ interface ReportInput {
14
14
 
15
15
  export async function reportCmd(argv: string[]): Promise<number> {
16
16
  const ticket = argv[0];
17
- const inputArg = argv.find(a => a.startsWith('--input='));
17
+ const inputArg = argv.find((a) => a.startsWith('--input='));
18
18
  if (!ticket || !inputArg) {
19
19
  console.error('[xera:report] usage: report <TICKET> --input=<classifier-output.json>');
20
20
  return 1;