@xera-ai/core 0.11.4 → 0.11.6
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.
package/dist/bin/internal.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
8527
|
-
|
|
8528
|
-
|
|
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);
|
|
@@ -8755,11 +8853,14 @@ async function authSetupCmd(argv) {
|
|
|
8755
8853
|
const mod = await import(pathToFileURL2(authSetupScript).href);
|
|
8756
8854
|
let exitCode = 0;
|
|
8757
8855
|
if ((opts.shape === "all" || opts.shape === "web") && config.web && typeof mod.web === "function") {
|
|
8856
|
+
const webConfig = config.web;
|
|
8857
|
+
const envName = process.env.XERA_ENV ?? webConfig.defaultEnv;
|
|
8858
|
+
const baseURL = process.env.XERA_BASE_URL ?? webConfig.baseUrl[envName] ?? webConfig.baseUrl[webConfig.defaultEnv];
|
|
8758
8859
|
const { runAuthSetup } = await import("@xera-ai/web");
|
|
8759
8860
|
const { chromium } = await import("@playwright/test");
|
|
8760
8861
|
const browser = await chromium.launch();
|
|
8761
8862
|
try {
|
|
8762
|
-
for (const [roleName, roleCreds] of Object.entries(
|
|
8863
|
+
for (const [roleName, roleCreds] of Object.entries(webConfig.auth.roles)) {
|
|
8763
8864
|
if (opts.role && roleName !== opts.role)
|
|
8764
8865
|
continue;
|
|
8765
8866
|
const email = process.env[roleCreds.envEmail];
|
|
@@ -8775,7 +8876,8 @@ async function authSetupCmd(argv) {
|
|
|
8775
8876
|
creds: { email, password },
|
|
8776
8877
|
setupScriptPath: authSetupScript,
|
|
8777
8878
|
authDir: join5(cwd, ".xera", ".auth"),
|
|
8778
|
-
browser
|
|
8879
|
+
browser,
|
|
8880
|
+
...baseURL ? { baseURL } : {}
|
|
8779
8881
|
});
|
|
8780
8882
|
console.log(`[xera:auth-setup] \u2713 ${roleName}.json (web)`);
|
|
8781
8883
|
} catch (e) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xera-ai/core",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.6",
|
|
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.
|
|
35
|
-
"@xera-ai/http": "^0.11.
|
|
34
|
+
"@xera-ai/web": "^0.11.6",
|
|
35
|
+
"@xera-ai/http": "^0.11.6",
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
if (!ticketId)
|
|
70
|
-
|
|
71
|
-
|
|
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();
|
|
@@ -50,11 +50,17 @@ export async function authSetupCmd(argv: string[]): Promise<number> {
|
|
|
50
50
|
config.web &&
|
|
51
51
|
typeof mod.web === 'function'
|
|
52
52
|
) {
|
|
53
|
+
const webConfig = config.web;
|
|
54
|
+
const envName = process.env.XERA_ENV ?? webConfig.defaultEnv;
|
|
55
|
+
const baseURL =
|
|
56
|
+
process.env.XERA_BASE_URL ??
|
|
57
|
+
webConfig.baseUrl[envName] ??
|
|
58
|
+
webConfig.baseUrl[webConfig.defaultEnv];
|
|
53
59
|
const { runAuthSetup } = await import('@xera-ai/web');
|
|
54
60
|
const { chromium } = await import('@playwright/test');
|
|
55
61
|
const browser = await chromium.launch();
|
|
56
62
|
try {
|
|
57
|
-
for (const [roleName, roleCreds] of Object.entries(
|
|
63
|
+
for (const [roleName, roleCreds] of Object.entries(webConfig.auth.roles)) {
|
|
58
64
|
if (opts.role && roleName !== opts.role) continue;
|
|
59
65
|
const email = process.env[roleCreds.envEmail];
|
|
60
66
|
const password = process.env[roleCreds.envPassword];
|
|
@@ -72,6 +78,7 @@ export async function authSetupCmd(argv: string[]): Promise<number> {
|
|
|
72
78
|
setupScriptPath: authSetupScript,
|
|
73
79
|
authDir: join(cwd, '.xera', '.auth'),
|
|
74
80
|
browser,
|
|
81
|
+
...(baseURL ? { baseURL } : {}),
|
|
75
82
|
});
|
|
76
83
|
console.log(`[xera:auth-setup] ✓ ${roleName}.json (web)`);
|
|
77
84
|
} catch (e) {
|
|
@@ -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:
|
|
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:
|
|
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
|
}
|
package/src/graph/store.ts
CHANGED
|
@@ -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)
|
package/src/graph/types.ts
CHANGED