@xera-ai/core 0.11.4 → 0.11.5

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.
@@ -668,6 +668,7 @@ function deriveSnapshot(events) {
668
668
  const latestFailures = {};
669
669
  const acNodes = {};
670
670
  const classifications = [];
671
+ const classByRun = new Map;
671
672
  for (const e of events) {
672
673
  switch (e.type) {
673
674
  case "ticket.fetched": {
@@ -778,6 +779,11 @@ function deriveSnapshot(events) {
778
779
  };
779
780
  if (e.payload.traceId)
780
781
  fail.traceId = e.payload.traceId;
782
+ const prior = classByRun.get(`${e.payload.scenarioId}:${e.payload.runId}`);
783
+ if (prior) {
784
+ fail.classification = prior.classification;
785
+ fail.confidence = prior.confidence;
786
+ }
781
787
  latestFailures[e.payload.scenarioId] = fail;
782
788
  } else {
783
789
  delete latestFailures[e.payload.scenarioId];
@@ -803,13 +809,23 @@ function deriveSnapshot(events) {
803
809
  }
804
810
  break;
805
811
  }
806
- case "run.classified":
812
+ case "run.classified": {
807
813
  classifications.push({
808
814
  scenarioId: e.payload.scenarioId,
809
815
  classification: e.payload.classification,
810
816
  ts: e.ts
811
817
  });
818
+ classByRun.set(`${e.payload.scenarioId}:${e.payload.runId}`, {
819
+ classification: e.payload.classification,
820
+ confidence: e.payload.confidence
821
+ });
822
+ const existing = latestFailures[e.payload.scenarioId];
823
+ if (existing && existing.runId === e.payload.runId) {
824
+ existing.classification = e.payload.classification;
825
+ existing.confidence = e.payload.confidence;
826
+ }
812
827
  break;
828
+ }
813
829
  case "ac-coverage.backfilled": {
814
830
  const { ts, ticketId, mappings } = e.payload;
815
831
  for (let i = edges.length - 1;i >= 0; i--) {
@@ -8186,6 +8202,60 @@ function makeEvent(actor, type, payload) {
8186
8202
  payload
8187
8203
  };
8188
8204
  }
8205
+ function levenshtein(a, b) {
8206
+ if (a === b)
8207
+ return 0;
8208
+ if (a.length === 0)
8209
+ return b.length;
8210
+ if (b.length === 0)
8211
+ return a.length;
8212
+ const m = a.length;
8213
+ const n = b.length;
8214
+ let prev = new Array(n + 1);
8215
+ let curr = new Array(n + 1);
8216
+ for (let j = 0;j <= n; j++)
8217
+ prev[j] = j;
8218
+ for (let i = 1;i <= m; i++) {
8219
+ curr[0] = i;
8220
+ for (let j = 1;j <= n; j++) {
8221
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
8222
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
8223
+ }
8224
+ [prev, curr] = [curr, prev];
8225
+ }
8226
+ return prev[n];
8227
+ }
8228
+ function findClosestName(target, candidates) {
8229
+ if (candidates.length === 0)
8230
+ return;
8231
+ const norm = target.trim().toLowerCase();
8232
+ let best;
8233
+ for (const c of candidates) {
8234
+ const d = levenshtein(norm, c.trim().toLowerCase());
8235
+ if (best === undefined || d < best.dist)
8236
+ best = { name: c, dist: d };
8237
+ }
8238
+ if (!best)
8239
+ return;
8240
+ const maxLen = Math.max(norm.length, best.name.trim().length);
8241
+ if (maxLen > 0 && best.dist > maxLen * 0.5)
8242
+ return;
8243
+ return best.name;
8244
+ }
8245
+ function warnUnmatchedScenarios(context, source, ticket, total, unmatched) {
8246
+ if (unmatched.length === 0)
8247
+ return;
8248
+ console.warn(`[graph-record ${context}] ${unmatched.length} of ${total} scenario name(s) in ${source} could not be matched to graph scenarios for ${ticket}.`);
8249
+ for (const u of unmatched) {
8250
+ console.warn(` Unmatched: "${u.name}"`);
8251
+ if (u.suggestion)
8252
+ console.warn(` Did you mean: "${u.suggestion}"?`);
8253
+ }
8254
+ }
8255
+ function knownScenariosForTicket(repoRoot, ticket) {
8256
+ const snap = deriveSnapshot(loadAllEvents(repoRoot));
8257
+ return Object.values(snap.scenarios).filter((s) => s.ticketId === ticket);
8258
+ }
8189
8259
  function readStoryFrontmatter(repoRoot, ticket) {
8190
8260
  const path = join10(repoRoot, ".xera", ticket, "story.md");
8191
8261
  if (!existsSync8(path))
@@ -8255,12 +8325,23 @@ async function recordExec(repoRoot, ticket, runId) {
8255
8325
  return 1;
8256
8326
  }
8257
8327
  const data = JSON.parse(readFileSync7(normalizedPath, "utf8"));
8328
+ const known = knownScenariosForTicket(repoRoot, ticket);
8329
+ const knownIds = new Set(known.map((s) => s.id));
8330
+ const knownNames = known.map((s) => s.name);
8258
8331
  const events = [];
8332
+ const unmatched = [];
8333
+ let considered = 0;
8259
8334
  for (const s of data.scenarios) {
8260
8335
  if (s.outcome === "SKIPPED")
8261
8336
  continue;
8337
+ considered++;
8338
+ const sid = scenarioId(ticket, s.name);
8339
+ if (known.length > 0 && !knownIds.has(sid)) {
8340
+ const suggestion = findClosestName(s.name, knownNames);
8341
+ unmatched.push(suggestion ? { name: s.name, suggestion } : { name: s.name });
8342
+ }
8262
8343
  const p = {
8263
- scenarioId: scenarioId(ticket, s.name),
8344
+ scenarioId: sid,
8264
8345
  ticketId: ticket,
8265
8346
  runId,
8266
8347
  status: s.outcome === "PASS" ? "pass" : "fail",
@@ -8268,6 +8349,7 @@ async function recordExec(repoRoot, ticket, runId) {
8268
8349
  };
8269
8350
  events.push(makeEvent("xera-exec", "run.completed", p));
8270
8351
  }
8352
+ warnUnmatchedScenarios("exec", "normalized.json", ticket, considered, unmatched);
8271
8353
  appendEvents(repoRoot, events, { skill: "xera-exec", ticketId: ticket });
8272
8354
  return 0;
8273
8355
  }
@@ -8279,16 +8361,26 @@ async function recordClassify(repoRoot, ticket, runId) {
8279
8361
  return 1;
8280
8362
  }
8281
8363
  const data = JSON.parse(readFileSync7(classifyPath, "utf8"));
8364
+ const known = knownScenariosForTicket(repoRoot, ticket);
8365
+ const knownIds = new Set(known.map((s) => s.id));
8366
+ const knownNames = known.map((s) => s.name);
8282
8367
  const events = [];
8368
+ const unmatched = [];
8283
8369
  for (const s of data.scenarios) {
8370
+ const sid = scenarioId(ticket, s.name);
8371
+ if (known.length > 0 && !knownIds.has(sid)) {
8372
+ const suggestion = findClosestName(s.name, knownNames);
8373
+ unmatched.push(suggestion ? { name: s.name, suggestion } : { name: s.name });
8374
+ }
8284
8375
  const p = {
8285
- scenarioId: scenarioId(ticket, s.name),
8376
+ scenarioId: sid,
8286
8377
  runId,
8287
8378
  classification: s.class,
8288
8379
  confidence: s.confidence
8289
8380
  };
8290
8381
  events.push(makeEvent("xera-report", "run.classified", p));
8291
8382
  }
8383
+ warnUnmatchedScenarios("classify", "classifier-input.json", ticket, data.scenarios.length, unmatched);
8292
8384
  appendEvents(repoRoot, events, { skill: "xera-report", ticketId: ticket });
8293
8385
  return 0;
8294
8386
  }
@@ -8518,14 +8610,20 @@ async function acCoverageBackfillFinalizeCmd(argv) {
8518
8610
  }
8519
8611
  if (parsed.mappings.length === 0)
8520
8612
  return 0;
8613
+ const snap = deriveSnapshot(loadAllEvents(cwd));
8521
8614
  const byTicket = {};
8522
8615
  for (const m of parsed.mappings) {
8523
- const ticketId = m.scenarioId.split("#")[0];
8524
- if (!ticketId)
8616
+ let ticketId = snap.scenarios[m.scenarioId]?.ticketId;
8617
+ if (!ticketId && m.scenarioId.includes("#")) {
8618
+ ticketId = m.scenarioId.split("#")[0];
8619
+ }
8620
+ if (!ticketId) {
8621
+ console.error(`[ac-coverage-backfill-finalize] cannot resolve ticketId for scenario ${m.scenarioId}; skipping`);
8525
8622
  continue;
8526
- if (!byTicket[ticketId])
8527
- byTicket[ticketId] = [];
8528
- byTicket[ticketId].push(m);
8623
+ }
8624
+ const bucket = byTicket[ticketId] ?? [];
8625
+ bucket.push(m);
8626
+ byTicket[ticketId] = bucket;
8529
8627
  }
8530
8628
  const ts = args.snapshotTs ?? new Date().toISOString();
8531
8629
  const now = new Date(ts);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xera-ai/core",
3
- "version": "0.11.4",
3
+ "version": "0.11.5",
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.11.4",
35
- "@xera-ai/http": "^0.11.4",
34
+ "@xera-ai/web": "^0.11.5",
35
+ "@xera-ai/http": "^0.11.5",
36
36
  "@playwright/test": "1.60.0",
37
37
  "dotenv": "^16.0.0",
38
38
  "fflate": "0.8.3",
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { z } from 'zod';
4
- import { appendEvents } from '../graph/store';
4
+ import { appendEvents, deriveSnapshot, loadAllEvents } from '../graph/store';
5
5
  import type { Event } from '../graph/types';
6
6
  import { SCHEMA_VERSION } from '../graph/types';
7
7
  import { ulid } from '../graph/ulid';
@@ -62,13 +62,28 @@ export async function acCoverageBackfillFinalizeCmd(argv: string[]): Promise<num
62
62
 
63
63
  if (parsed.mappings.length === 0) return 0;
64
64
 
65
- // Group mappings by ticketId (extracted from scenarioId prefix)
65
+ const snap = deriveSnapshot(loadAllEvents(cwd));
66
+
67
+ // Group mappings by ticketId. Resolve via the snapshot's scenario nodes —
68
+ // scenarioIds are content-derived hashes, not `${ticketId}#...`, so parsing
69
+ // the prefix would put every scenario in its own bucket (and the resulting
70
+ // payload.ticketId would be the hash). Fall back to a `#`-split only for
71
+ // legacy scenarioIds shaped like `${ticketId}#scenario-N`.
66
72
  const byTicket: Record<string, z.infer<typeof DecisionsSchema>['mappings']> = {};
67
73
  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);
74
+ let ticketId = snap.scenarios[m.scenarioId]?.ticketId;
75
+ if (!ticketId && m.scenarioId.includes('#')) {
76
+ ticketId = m.scenarioId.split('#')[0];
77
+ }
78
+ if (!ticketId) {
79
+ console.error(
80
+ `[ac-coverage-backfill-finalize] cannot resolve ticketId for scenario ${m.scenarioId}; skipping`,
81
+ );
82
+ continue;
83
+ }
84
+ const bucket = byTicket[ticketId] ?? [];
85
+ bucket.push(m);
86
+ byTicket[ticketId] = bucket;
72
87
  }
73
88
 
74
89
  const ts = args.snapshotTs ?? new Date().toISOString();
@@ -3,7 +3,7 @@ import { existsSync, readFileSync } from 'node:fs';
3
3
  import { basename, join } from 'node:path';
4
4
  import { parse as parseYaml } from 'yaml';
5
5
  import { resolveArtifactPaths } from '../artifact/paths';
6
- import { appendEvents } from '../graph/store';
6
+ import { appendEvents, deriveSnapshot, loadAllEvents } from '../graph/store';
7
7
  import type {
8
8
  Classification,
9
9
  ClassificationDisputedPayload,
@@ -12,6 +12,7 @@ import type {
12
12
  PomPromotedPayload,
13
13
  RunClassifiedPayload,
14
14
  RunCompletedPayload,
15
+ ScenarioNode,
15
16
  TicketFetchedPayload,
16
17
  } from '../graph/types';
17
18
  import { SCHEMA_VERSION } from '../graph/types';
@@ -55,6 +56,64 @@ interface StoryFrontmatter {
55
56
  }>;
56
57
  }
57
58
 
59
+ function levenshtein(a: string, b: string): number {
60
+ if (a === b) return 0;
61
+ if (a.length === 0) return b.length;
62
+ if (b.length === 0) return a.length;
63
+ const m = a.length;
64
+ const n = b.length;
65
+ let prev = new Array<number>(n + 1);
66
+ let curr = new Array<number>(n + 1);
67
+ for (let j = 0; j <= n; j++) prev[j] = j;
68
+ for (let i = 1; i <= m; i++) {
69
+ curr[0] = i;
70
+ for (let j = 1; j <= n; j++) {
71
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
72
+ curr[j] = Math.min(prev[j]! + 1, curr[j - 1]! + 1, prev[j - 1]! + cost);
73
+ }
74
+ [prev, curr] = [curr, prev];
75
+ }
76
+ return prev[n]!;
77
+ }
78
+
79
+ function findClosestName(target: string, candidates: string[]): string | undefined {
80
+ if (candidates.length === 0) return undefined;
81
+ const norm = target.trim().toLowerCase();
82
+ let best: { name: string; dist: number } | undefined;
83
+ for (const c of candidates) {
84
+ const d = levenshtein(norm, c.trim().toLowerCase());
85
+ if (best === undefined || d < best.dist) best = { name: c, dist: d };
86
+ }
87
+ if (!best) return undefined;
88
+ // Only suggest when the candidate is within 50% edit distance of the target —
89
+ // otherwise the "Did you mean" line is noise.
90
+ const maxLen = Math.max(norm.length, best.name.trim().length);
91
+ if (maxLen > 0 && best.dist > maxLen * 0.5) return undefined;
92
+ return best.name;
93
+ }
94
+
95
+ function warnUnmatchedScenarios(
96
+ context: 'exec' | 'classify',
97
+ source: string,
98
+ ticket: string,
99
+ total: number,
100
+ unmatched: Array<{ name: string; suggestion?: string }>,
101
+ ): void {
102
+ if (unmatched.length === 0) return;
103
+ console.warn(
104
+ `[graph-record ${context}] ${unmatched.length} of ${total} scenario name(s) in ${source} could not be matched to graph scenarios for ${ticket}.`,
105
+ );
106
+ for (const u of unmatched) {
107
+ console.warn(` Unmatched: "${u.name}"`);
108
+ if (u.suggestion) console.warn(` Did you mean: "${u.suggestion}"?`);
109
+ }
110
+ }
111
+
112
+ function knownScenariosForTicket(repoRoot: string, ticket: string): ScenarioNode[] {
113
+ const snap = deriveSnapshot(loadAllEvents(repoRoot));
114
+ return Object.values(snap.scenarios).filter((s) => s.ticketId === ticket);
115
+ }
116
+
58
117
  function readStoryFrontmatter(repoRoot: string, ticket: string): StoryFrontmatter | null {
59
118
  const path = join(repoRoot, '.xera', ticket, 'story.md');
60
119
  if (!existsSync(path)) return null;
@@ -127,11 +186,22 @@ async function recordExec(repoRoot: string, ticket: string, runId: string): Prom
127
186
  const data = JSON.parse(readFileSync(normalizedPath, 'utf8')) as {
128
187
  scenarios: Array<{ name: string; outcome: 'PASS' | 'FAIL' | 'SKIPPED' }>;
129
188
  };
189
+ const known = knownScenariosForTicket(repoRoot, ticket);
190
+ const knownIds = new Set(known.map((s) => s.id));
191
+ const knownNames = known.map((s) => s.name);
130
192
  const events: Event[] = [];
193
+ const unmatched: Array<{ name: string; suggestion?: string }> = [];
194
+ let considered = 0;
131
195
  for (const s of data.scenarios) {
132
196
  if (s.outcome === 'SKIPPED') continue;
197
+ considered++;
198
+ const sid = scenarioId(ticket, s.name);
199
+ if (known.length > 0 && !knownIds.has(sid)) {
200
+ const suggestion = findClosestName(s.name, knownNames);
201
+ unmatched.push(suggestion ? { name: s.name, suggestion } : { name: s.name });
202
+ }
133
203
  const p: RunCompletedPayload = {
134
- scenarioId: scenarioId(ticket, s.name),
204
+ scenarioId: sid,
135
205
  ticketId: ticket,
136
206
  runId,
137
207
  status: s.outcome === 'PASS' ? 'pass' : 'fail',
@@ -139,6 +209,7 @@ async function recordExec(repoRoot: string, ticket: string, runId: string): Prom
139
209
  };
140
210
  events.push(makeEvent('xera-exec', 'run.completed', p));
141
211
  }
212
+ warnUnmatchedScenarios('exec', 'normalized.json', ticket, considered, unmatched);
142
213
  appendEvents(repoRoot, events, { skill: 'xera-exec', ticketId: ticket });
143
214
  return 0;
144
215
  }
@@ -153,16 +224,32 @@ async function recordClassify(repoRoot: string, ticket: string, runId: string):
153
224
  const data = JSON.parse(readFileSync(classifyPath, 'utf8')) as {
154
225
  scenarios: Array<{ name: string; class: string; confidence: 'low' | 'medium' | 'high' }>;
155
226
  };
227
+ const known = knownScenariosForTicket(repoRoot, ticket);
228
+ const knownIds = new Set(known.map((s) => s.id));
229
+ const knownNames = known.map((s) => s.name);
156
230
  const events: Event[] = [];
231
+ const unmatched: Array<{ name: string; suggestion?: string }> = [];
157
232
  for (const s of data.scenarios) {
233
+ const sid = scenarioId(ticket, s.name);
234
+ if (known.length > 0 && !knownIds.has(sid)) {
235
+ const suggestion = findClosestName(s.name, knownNames);
236
+ unmatched.push(suggestion ? { name: s.name, suggestion } : { name: s.name });
237
+ }
158
238
  const p: RunClassifiedPayload = {
159
- scenarioId: scenarioId(ticket, s.name),
239
+ scenarioId: sid,
160
240
  runId,
161
241
  classification: s.class as RunClassifiedPayload['classification'],
162
242
  confidence: s.confidence,
163
243
  };
164
244
  events.push(makeEvent('xera-report', 'run.classified', p));
165
245
  }
246
+ warnUnmatchedScenarios(
247
+ 'classify',
248
+ 'classifier-input.json',
249
+ ticket,
250
+ data.scenarios.length,
251
+ unmatched,
252
+ );
166
253
  appendEvents(repoRoot, events, { skill: 'xera-report', ticketId: ticket });
167
254
  return 0;
168
255
  }
@@ -106,6 +106,14 @@ export function deriveSnapshot(events: Event[]): Snapshot {
106
106
  classification: Classification;
107
107
  ts: string;
108
108
  }> = [];
109
+ // Side index keyed by `${scenarioId}:${runId}` so a run.classified event can
110
+ // either join onto an existing FailureNode or be picked up when its
111
+ // run.completed sibling arrives later (event order isn't guaranteed —
112
+ // events live in separate JSONL files written by separate skills).
113
+ const classByRun = new Map<
114
+ string,
115
+ { classification: Classification; confidence: 'low' | 'medium' | 'high' }
116
+ >();
109
117
 
110
118
  for (const e of events) {
111
119
  switch (e.type) {
@@ -218,6 +226,11 @@ export function deriveSnapshot(events: Event[]): Snapshot {
218
226
  ts: e.ts,
219
227
  };
220
228
  if (e.payload.traceId) fail.traceId = e.payload.traceId;
229
+ const prior = classByRun.get(`${e.payload.scenarioId}:${e.payload.runId}`);
230
+ if (prior) {
231
+ fail.classification = prior.classification;
232
+ fail.confidence = prior.confidence;
233
+ }
221
234
  latestFailures[e.payload.scenarioId] = fail;
222
235
  } else {
223
236
  delete latestFailures[e.payload.scenarioId];
@@ -242,13 +255,26 @@ export function deriveSnapshot(events: Event[]): Snapshot {
242
255
  }
243
256
  break;
244
257
  }
245
- case 'run.classified':
258
+ case 'run.classified': {
246
259
  classifications.push({
247
260
  scenarioId: e.payload.scenarioId,
248
261
  classification: e.payload.classification,
249
262
  ts: e.ts,
250
263
  });
264
+ classByRun.set(`${e.payload.scenarioId}:${e.payload.runId}`, {
265
+ classification: e.payload.classification,
266
+ confidence: e.payload.confidence,
267
+ });
268
+ // If the matching FailureNode is already in latest_failures, project
269
+ // the classification onto it now so the snapshot is correct regardless
270
+ // of which event arrived first.
271
+ const existing = latestFailures[e.payload.scenarioId];
272
+ if (existing && existing.runId === e.payload.runId) {
273
+ existing.classification = e.payload.classification;
274
+ existing.confidence = e.payload.confidence;
275
+ }
251
276
  break;
277
+ }
252
278
  case 'ac-coverage.backfilled': {
253
279
  const { ts, ticketId, mappings } = e.payload;
254
280
  // Remove prior backfill edges for this ticket (idempotent)
@@ -207,6 +207,8 @@ export interface FailureNode {
207
207
  traceId?: string;
208
208
  ts: string;
209
209
  disputed?: boolean;
210
+ classification?: Classification;
211
+ confidence?: 'low' | 'medium' | 'high';
210
212
  }
211
213
 
212
214
  export interface EdgeRecord {