@xera-ai/core 0.13.0 → 0.14.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.
@@ -490,6 +490,7 @@ var init_schema = __esm(() => {
490
490
  "SELECTOR_DRIFT",
491
491
  "FLAKY",
492
492
  "PASS",
493
+ "SKIPPED",
493
494
  "TEST_OUTDATED",
494
495
  "CONTRACT_DRIFT",
495
496
  "RATE_LIMITED",
@@ -828,9 +829,10 @@ function deriveSnapshot(events) {
828
829
  }
829
830
  case "ac-coverage.backfilled": {
830
831
  const { ts, ticketId, mappings } = e.payload;
832
+ const touchedScenarios = new Set(mappings.map((m) => m.scenarioId));
831
833
  for (let i = edges.length - 1;i >= 0; i--) {
832
834
  const ed = edges[i];
833
- if (ed.kind === "satisfies" && ed.source === "ac-coverage" && ed.to.startsWith(`${ticketId}#ac-`)) {
835
+ if (ed.kind === "satisfies" && ed.source === "ac-coverage" && ed.to.startsWith(`${ticketId}#ac-`) && touchedScenarios.has(ed.from)) {
834
836
  edges.splice(i, 1);
835
837
  }
836
838
  }
@@ -8175,6 +8177,7 @@ var init_paths2 = __esm(() => {
8175
8177
  var exports_graph_record = {};
8176
8178
  __export(exports_graph_record, {
8177
8179
  recordFetch: () => recordFetch,
8180
+ recordExec: () => recordExec,
8178
8181
  graphRecordCmd: () => graphRecordCmd
8179
8182
  });
8180
8183
  import { createHash as createHash3 } from "crypto";
@@ -8476,6 +8479,7 @@ async function graphRecordCmd(argv) {
8476
8479
  "SELECTOR_DRIFT",
8477
8480
  "FLAKY",
8478
8481
  "PASS",
8482
+ "SKIPPED",
8479
8483
  "TEST_OUTDATED",
8480
8484
  "CONTRACT_DRIFT",
8481
8485
  "RATE_LIMITED",
@@ -8657,15 +8661,15 @@ function findUnmapped(snap) {
8657
8661
  const ticketScenarios = Object.values(snap.scenarios).filter((s) => s.ticketId === ticket.id);
8658
8662
  if (ticketScenarios.length === 0)
8659
8663
  continue;
8660
- const acsForTicket = Object.values(snap.acNodes).filter((ac) => ac.ticketId === ticket.id);
8661
- const hasAnyEdge = snap.edges.some((e) => e.kind === "satisfies" && acsForTicket.some((ac) => ac.id === e.to));
8662
- if (hasAnyEdge)
8664
+ const acIdsForTicket = new Set(Object.values(snap.acNodes).filter((ac) => ac.ticketId === ticket.id).map((ac) => ac.id));
8665
+ const unmappedScenarios = ticketScenarios.filter((s) => !snap.edges.some((e) => e.kind === "satisfies" && e.from === s.id && acIdsForTicket.has(e.to)));
8666
+ if (unmappedScenarios.length === 0)
8663
8667
  continue;
8664
8668
  out.push({
8665
8669
  id: ticket.id,
8666
8670
  summary: ticket.summary,
8667
8671
  acs: ticket.ac,
8668
- scenarios: ticketScenarios.map((s) => ({
8672
+ scenarios: unmappedScenarios.map((s) => ({
8669
8673
  id: s.id,
8670
8674
  name: s.name,
8671
8675
  gherkin: s.gherkin
@@ -9094,15 +9098,17 @@ function buildCoverageReport(snap, config, now) {
9094
9098
  }
9095
9099
  function needsBackfill(snap) {
9096
9100
  for (const ticket of Object.values(snap.tickets)) {
9097
- const acsForTicket = Object.values(snap.acNodes).filter((ac) => ac.ticketId === ticket.id);
9098
- if (acsForTicket.length === 0)
9101
+ const acIdsForTicket = new Set(Object.values(snap.acNodes).filter((ac) => ac.ticketId === ticket.id).map((ac) => ac.id));
9102
+ if (acIdsForTicket.size === 0)
9099
9103
  continue;
9100
9104
  const scenariosForTicket = Object.values(snap.scenarios).filter((s) => s.ticketId === ticket.id);
9101
9105
  if (scenariosForTicket.length === 0)
9102
9106
  continue;
9103
- const hasAnyEdge = snap.edges.some((e) => e.kind === "satisfies" && acsForTicket.some((ac) => ac.id === e.to));
9104
- if (!hasAnyEdge)
9105
- return true;
9107
+ for (const s of scenariosForTicket) {
9108
+ const mapped = snap.edges.some((e) => e.kind === "satisfies" && e.from === s.id && acIdsForTicket.has(e.to));
9109
+ if (!mapped)
9110
+ return true;
9111
+ }
9106
9112
  }
9107
9113
  return false;
9108
9114
  }
@@ -12656,10 +12662,11 @@ async function lintCmd(argv) {
12656
12662
  import { existsSync as existsSync27, readdirSync as readdirSync7 } from "fs";
12657
12663
  import { join as join28 } from "path";
12658
12664
  init_paths2();
12665
+ init_graph_record();
12659
12666
  async function normalizeCmd(argv) {
12660
12667
  const ticket = argv[0];
12661
12668
  if (!ticket) {
12662
- console.error("[xera:normalize] usage: normalize <TICKET> [--run=<runId>]");
12669
+ console.error("[xera:normalize] usage: normalize <TICKET> [--run=<runId>] [--no-graph-record]");
12663
12670
  return 1;
12664
12671
  }
12665
12672
  const paths = resolveArtifactPaths(process.cwd(), ticket);
@@ -12674,17 +12681,24 @@ async function normalizeCmd(argv) {
12674
12681
  console.error(`[xera:normalize] runs/${runId} missing`);
12675
12682
  return 1;
12676
12683
  }
12684
+ const skipGraphRecord = argv.includes("--no-graph-record");
12677
12685
  const meta = readMeta(paths.metaPath);
12678
12686
  const adapter = meta?.adapter ?? "web";
12679
12687
  if (adapter === "http") {
12680
12688
  const { normalizeHttpRun } = await import("@xera-ai/http");
12681
12689
  await normalizeHttpRun({ runId, runDir });
12682
12690
  console.log(`[xera:normalize] wrote normalized.json (http)`);
12683
- return 0;
12691
+ } else {
12692
+ const { normalizeRun } = await import("@xera-ai/web");
12693
+ const r = await normalizeRun({ runId, runDir });
12694
+ console.log(`[xera:normalize] wrote normalized.json (scrubbed_fields_count=${r.scrubbed_fields_count})`);
12695
+ }
12696
+ if (!skipGraphRecord) {
12697
+ const code = await recordExec(process.cwd(), ticket, runId);
12698
+ if (code !== 0) {
12699
+ console.warn(`[xera:normalize] graph-record exec exited ${code} \u2014 continuing (non-fatal)`);
12700
+ }
12684
12701
  }
12685
- const { normalizeRun } = await import("@xera-ai/web");
12686
- const r = await normalizeRun({ runId, runDir });
12687
- console.log(`[xera:normalize] wrote normalized.json (scrubbed_fields_count=${r.scrubbed_fields_count})`);
12688
12702
  return 0;
12689
12703
  }
12690
12704
 
@@ -12699,6 +12713,7 @@ import { dirname as dirname8 } from "path";
12699
12713
  import { z as z10 } from "zod";
12700
12714
  var ClassificationEnum = z10.enum([
12701
12715
  "PASS",
12716
+ "SKIPPED",
12702
12717
  "REAL_BUG",
12703
12718
  "SELECTOR_DRIFT",
12704
12719
  "FLAKY",
@@ -12963,7 +12978,7 @@ function classifyRateLimited(input) {
12963
12978
 
12964
12979
  // src/graph/classify.ts
12965
12980
  var DEFAULT_THRESHOLD = 0.7;
12966
- var SHORT_CIRCUIT = ["FLAKY", "PASS"];
12981
+ var SHORT_CIRCUIT = ["FLAKY", "PASS", "SKIPPED"];
12967
12982
  function findCandidateTickets(graph, scenario) {
12968
12983
  const poms = graph.edges.filter((e) => e.kind === "uses" && e.from === scenario.id).map((e) => e.to);
12969
12984
  if (poms.length === 0)
package/dist/src/index.js CHANGED
@@ -146,6 +146,7 @@ import { dirname as dirname2 } from "path";
146
146
  import { z as z2 } from "zod";
147
147
  var ClassificationEnum = z2.enum([
148
148
  "PASS",
149
+ "SKIPPED",
149
150
  "REAL_BUG",
150
151
  "SELECTOR_DRIFT",
151
152
  "FLAKY",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xera-ai/core",
3
- "version": "0.13.0",
3
+ "version": "0.14.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.13.0",
35
- "@xera-ai/http": "^0.13.0",
34
+ "@xera-ai/web": "^0.14.0",
35
+ "@xera-ai/http": "^0.14.0",
36
36
  "@playwright/test": "1.60.0",
37
37
  "dotenv": "^16.0.0",
38
38
  "fflate": "0.8.3",
@@ -4,6 +4,7 @@ import { z } from 'zod';
4
4
 
5
5
  const ClassificationEnum = z.enum([
6
6
  'PASS',
7
+ 'SKIPPED',
7
8
  'REAL_BUG',
8
9
  'SELECTOR_DRIFT',
9
10
  'FLAKY',
@@ -18,16 +18,26 @@ function findUnmapped(snap: Snapshot): BackfillInput {
18
18
  if (ticket.ac.length === 0) continue;
19
19
  const ticketScenarios = Object.values(snap.scenarios).filter((s) => s.ticketId === ticket.id);
20
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),
21
+ const acIdsForTicket = new Set(
22
+ Object.values(snap.acNodes)
23
+ .filter((ac) => ac.ticketId === ticket.id)
24
+ .map((ac) => ac.id),
24
25
  );
25
- if (hasAnyEdge) continue;
26
+ // Per-scenario: a scenario is "unmapped" if it has no `satisfies` edge to
27
+ // any of this ticket's ACs. Surface only those — finalize is now additive
28
+ // (#119) so partial mappings won't clobber prior edges.
29
+ const unmappedScenarios = ticketScenarios.filter(
30
+ (s) =>
31
+ !snap.edges.some(
32
+ (e) => e.kind === 'satisfies' && e.from === s.id && acIdsForTicket.has(e.to),
33
+ ),
34
+ );
35
+ if (unmappedScenarios.length === 0) continue;
26
36
  out.push({
27
37
  id: ticket.id,
28
38
  summary: ticket.summary,
29
39
  acs: ticket.ac,
30
- scenarios: ticketScenarios.map((s) => ({
40
+ scenarios: unmappedScenarios.map((s) => ({
31
41
  id: s.id,
32
42
  name: s.name,
33
43
  gherkin: s.gherkin,
@@ -190,7 +190,7 @@ async function recordScript(repoRoot: string, ticket: string): Promise<number> {
190
190
  return recordScriptImpl(repoRoot, ticket);
191
191
  }
192
192
 
193
- async function recordExec(repoRoot: string, ticket: string, runId: string): Promise<number> {
193
+ export async function recordExec(repoRoot: string, ticket: string, runId: string): Promise<number> {
194
194
  const { normalizedPath } = resolveArtifactPaths(repoRoot, ticket).runPath(runId);
195
195
  if (!existsSync(normalizedPath)) {
196
196
  console.error(`[graph-record exec] normalized.json missing`);
@@ -362,6 +362,7 @@ export async function graphRecordCmd(argv: string[]): Promise<number> {
362
362
  'SELECTOR_DRIFT',
363
363
  'FLAKY',
364
364
  'PASS',
365
+ 'SKIPPED',
365
366
  'TEST_OUTDATED',
366
367
  'CONTRACT_DRIFT',
367
368
  'RATE_LIMITED',
@@ -2,11 +2,12 @@ import { existsSync, readdirSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { readMeta } from '../artifact/meta';
4
4
  import { resolveArtifactPaths } from '../artifact/paths';
5
+ import { recordExec } from './graph-record';
5
6
 
6
7
  export async function normalizeCmd(argv: string[]): Promise<number> {
7
8
  const ticket = argv[0];
8
9
  if (!ticket) {
9
- console.error('[xera:normalize] usage: normalize <TICKET> [--run=<runId>]');
10
+ console.error('[xera:normalize] usage: normalize <TICKET> [--run=<runId>] [--no-graph-record]');
10
11
  return 1;
11
12
  }
12
13
  const paths = resolveArtifactPaths(process.cwd(), ticket);
@@ -26,6 +27,7 @@ export async function normalizeCmd(argv: string[]): Promise<number> {
26
27
  console.error(`[xera:normalize] runs/${runId} missing`);
27
28
  return 1;
28
29
  }
30
+ const skipGraphRecord = argv.includes('--no-graph-record');
29
31
 
30
32
  const meta = readMeta(paths.metaPath);
31
33
  const adapter = meta?.adapter ?? 'web';
@@ -34,13 +36,23 @@ export async function normalizeCmd(argv: string[]): Promise<number> {
34
36
  const { normalizeHttpRun } = await import('@xera-ai/http');
35
37
  await normalizeHttpRun({ runId, runDir });
36
38
  console.log(`[xera:normalize] wrote normalized.json (http)`);
37
- return 0;
39
+ } else {
40
+ const { normalizeRun } = await import('@xera-ai/web');
41
+ const r = await normalizeRun({ runId, runDir });
42
+ console.log(
43
+ `[xera:normalize] wrote normalized.json (scrubbed_fields_count=${r.scrubbed_fields_count})`,
44
+ );
38
45
  }
39
46
 
40
- const { normalizeRun } = await import('@xera-ai/web');
41
- const r = await normalizeRun({ runId, runDir });
42
- console.log(
43
- `[xera:normalize] wrote normalized.json (scrubbed_fields_count=${r.scrubbed_fields_count})`,
44
- );
47
+ // Emit run.completed events so the graph's latest_failures (and any
48
+ // recency-based risk scoring) reflect this run. Without this the
49
+ // /xera-run pipeline silently renders failed scenarios as green in
50
+ // graph.html — see #118.
51
+ if (!skipGraphRecord) {
52
+ const code = await recordExec(process.cwd(), ticket, runId);
53
+ if (code !== 0) {
54
+ console.warn(`[xera:normalize] graph-record exec exited ${code} — continuing (non-fatal)`);
55
+ }
56
+ }
45
57
  return 0;
46
58
  }
@@ -114,17 +114,26 @@ export function buildCoverageReport(
114
114
  }
115
115
 
116
116
  function needsBackfill(snap: Snapshot): boolean {
117
+ // True when at least one scenario lacks a `satisfies` edge to its ticket's
118
+ // ACs. Mirrors `findUnmapped` in ac-coverage-backfill-prepare so the flag
119
+ // and the prepare output stay consistent for partially mapped tickets (#119).
117
120
  for (const ticket of Object.values(snap.tickets)) {
118
- const acsForTicket = Object.values(snap.acNodes).filter((ac) => ac.ticketId === ticket.id);
119
- if (acsForTicket.length === 0) continue;
121
+ const acIdsForTicket = new Set(
122
+ Object.values(snap.acNodes)
123
+ .filter((ac) => ac.ticketId === ticket.id)
124
+ .map((ac) => ac.id),
125
+ );
126
+ if (acIdsForTicket.size === 0) continue;
120
127
  const scenariosForTicket = Object.values(snap.scenarios).filter(
121
128
  (s) => s.ticketId === ticket.id,
122
129
  );
123
130
  if (scenariosForTicket.length === 0) continue;
124
- const hasAnyEdge = snap.edges.some(
125
- (e) => e.kind === 'satisfies' && acsForTicket.some((ac) => ac.id === e.to),
126
- );
127
- if (!hasAnyEdge) return true;
131
+ for (const s of scenariosForTicket) {
132
+ const mapped = snap.edges.some(
133
+ (e) => e.kind === 'satisfies' && e.from === s.id && acIdsForTicket.has(e.to),
134
+ );
135
+ if (!mapped) return true;
136
+ }
128
137
  }
129
138
  return false;
130
139
  }
@@ -43,7 +43,7 @@ export type DecideOutdated = (args: {
43
43
  }) => Promise<OutdatedDecision>;
44
44
 
45
45
  const DEFAULT_THRESHOLD = 0.7;
46
- const SHORT_CIRCUIT: Classification[] = ['FLAKY', 'PASS'];
46
+ const SHORT_CIRCUIT: Classification[] = ['FLAKY', 'PASS', 'SKIPPED'];
47
47
 
48
48
  export function findCandidateTickets(graph: Snapshot, scenario: ScenarioNode): TicketNode[] {
49
49
  const poms = graph.edges
@@ -78,6 +78,7 @@ const classification = z.enum([
78
78
  'SELECTOR_DRIFT',
79
79
  'FLAKY',
80
80
  'PASS',
81
+ 'SKIPPED',
81
82
  'TEST_OUTDATED',
82
83
  'CONTRACT_DRIFT',
83
84
  'RATE_LIMITED',
@@ -277,13 +277,18 @@ export function deriveSnapshot(events: Event[]): Snapshot {
277
277
  }
278
278
  case 'ac-coverage.backfilled': {
279
279
  const { ts, ticketId, mappings } = e.payload;
280
- // Remove prior backfill edges for this ticket (idempotent)
280
+ // Upsert per scenarioId: drop only the prior backfill edges from the
281
+ // scenarios this event maps, then add the new edges. Scenarios not
282
+ // mentioned in the payload keep their prior edges so partial backfills
283
+ // are additive (matches every other handler — see #119).
284
+ const touchedScenarios = new Set(mappings.map((m) => m.scenarioId));
281
285
  for (let i = edges.length - 1; i >= 0; i--) {
282
286
  const ed = edges[i]!;
283
287
  if (
284
288
  ed.kind === 'satisfies' &&
285
289
  ed.source === 'ac-coverage' &&
286
- ed.to.startsWith(`${ticketId}#ac-`)
290
+ ed.to.startsWith(`${ticketId}#ac-`) &&
291
+ touchedScenarios.has(ed.from)
287
292
  ) {
288
293
  edges.splice(i, 1);
289
294
  }
@@ -20,6 +20,7 @@ export type Classification =
20
20
  | 'SELECTOR_DRIFT'
21
21
  | 'FLAKY'
22
22
  | 'PASS'
23
+ | 'SKIPPED'
23
24
  | 'TEST_OUTDATED'
24
25
  | 'CONTRACT_DRIFT'
25
26
  | 'RATE_LIMITED'