@xera-ai/core 0.4.1 → 0.4.3

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.
@@ -8882,11 +8882,18 @@ var ReportingSchema = z4.object({
8882
8882
  }).prefault({}),
8883
8883
  artifactLinks: z4.enum(["git", "local"]).default("git")
8884
8884
  }).prefault({});
8885
+ var RunSchema = z4.object({
8886
+ autoImpact: z4.object({
8887
+ enabled: z4.boolean().default(true),
8888
+ threshold: z4.number().nonnegative().default(6)
8889
+ }).prefault({})
8890
+ }).prefault({});
8885
8891
  var XeraConfigSchema = z4.object({
8886
8892
  jira: JiraSchema,
8887
8893
  web: WebSchema,
8888
8894
  ai: AISchema,
8889
8895
  reporting: ReportingSchema,
8896
+ run: RunSchema.prefault({}),
8890
8897
  adapters: z4.array(z4.string().min(1)).min(1).default(["web"])
8891
8898
  });
8892
8899
 
@@ -9450,6 +9457,306 @@ async function graphQueryCmd(argv) {
9450
9457
  // src/bin-internal/index.ts
9451
9458
  init_graph_record();
9452
9459
 
9460
+ // src/bin-internal/graph-render.ts
9461
+ import { mkdirSync as mkdirSync11, renameSync as renameSync2, writeFileSync as writeFileSync10 } from "fs";
9462
+ import { dirname as dirname7, join as join17 } from "path";
9463
+
9464
+ // src/graph/render.ts
9465
+ import { readFileSync as readFileSync17 } from "fs";
9466
+ import { dirname as dirname6, join as join16 } from "path";
9467
+ import { fileURLToPath } from "url";
9468
+ var COLORS = {
9469
+ ticket: "#3B82F6",
9470
+ scenarioPass: "#10B981",
9471
+ scenarioFail: "#EF4444",
9472
+ pom: "#F59E0B",
9473
+ area: "#6B7280",
9474
+ failure: "#EF4444",
9475
+ edgeModifies: "#EF4444",
9476
+ edgeDefault: "#9CA3AF",
9477
+ edgeJira: "#3B82F6",
9478
+ edgeSimilar: "#A855F7"
9479
+ };
9480
+ function ticketsAfter(since, fetchedAt) {
9481
+ if (!since)
9482
+ return true;
9483
+ return Date.parse(fetchedAt) >= Date.parse(since);
9484
+ }
9485
+ function scenariosAfter(since, generatedAt) {
9486
+ if (!since)
9487
+ return true;
9488
+ return Date.parse(generatedAt) >= Date.parse(since);
9489
+ }
9490
+ function buildTicketNode(snap, ticketId) {
9491
+ const t = snap.tickets[ticketId];
9492
+ const usageCount = snap.edges.filter((e) => e.kind === "tests" && e.from === ticketId).length;
9493
+ const node = {
9494
+ id: t.id,
9495
+ label: t.id,
9496
+ group: "Ticket",
9497
+ color: COLORS.ticket,
9498
+ shape: "dot",
9499
+ size: 10 + Math.min(usageCount * 2, 20),
9500
+ title: `${t.id} \u2014 ${t.summary}`
9501
+ };
9502
+ return node;
9503
+ }
9504
+ function buildScenarioNode(snap, scenarioId2) {
9505
+ const s = snap.scenarios[scenarioId2];
9506
+ const failed = snap.latest_failures[scenarioId2];
9507
+ const sizeBase = s.priority === "p0" ? 14 : s.priority === "p1" ? 11 : 9;
9508
+ const node = {
9509
+ id: s.id,
9510
+ label: s.name,
9511
+ group: "Scenario",
9512
+ color: failed ? COLORS.scenarioFail : COLORS.scenarioPass,
9513
+ shape: "square",
9514
+ size: sizeBase,
9515
+ title: `${s.ticketId} / ${s.name} [${s.priority.toUpperCase()}]`
9516
+ };
9517
+ return node;
9518
+ }
9519
+ function buildPomNode(snap, pomId2) {
9520
+ const p = snap.poms[pomId2];
9521
+ const usageCount = snap.edges.filter((e) => e.kind === "uses" && e.to === pomId2).length;
9522
+ const node = {
9523
+ id: p.id,
9524
+ label: p.filePath.split("/").pop() ?? p.id,
9525
+ group: "POM",
9526
+ color: COLORS.pom,
9527
+ shape: "diamond",
9528
+ size: 8 + Math.min(usageCount * 2, 16),
9529
+ title: `${p.filePath} (${p.route || "no route"})`
9530
+ };
9531
+ return node;
9532
+ }
9533
+ function buildAreaNode(snap, areaId) {
9534
+ const a = snap.areas[areaId];
9535
+ const node = {
9536
+ id: a.id,
9537
+ label: a.id,
9538
+ group: "SUTArea",
9539
+ color: COLORS.area,
9540
+ shape: "hexagon",
9541
+ size: 12,
9542
+ title: `area: ${a.id}`
9543
+ };
9544
+ return node;
9545
+ }
9546
+ function buildFailureNode(_snap, failure) {
9547
+ const node = {
9548
+ id: failure.id,
9549
+ label: "fail",
9550
+ group: "Failure",
9551
+ color: COLORS.failure,
9552
+ shape: "triangle",
9553
+ size: 10,
9554
+ title: `failure on ${failure.scenarioId} @ ${failure.ts}`
9555
+ };
9556
+ return node;
9557
+ }
9558
+ function buildEdge(edge, idx) {
9559
+ const v = {
9560
+ id: `e-${idx}`,
9561
+ from: edge.from,
9562
+ to: edge.to,
9563
+ label: edge.kind,
9564
+ arrows: "to",
9565
+ width: 1
9566
+ };
9567
+ switch (edge.kind) {
9568
+ case "modifies":
9569
+ v.color = COLORS.edgeModifies;
9570
+ v.dashes = true;
9571
+ v.width = 2;
9572
+ break;
9573
+ case "jira-linked":
9574
+ v.color = COLORS.edgeJira;
9575
+ v.dashes = true;
9576
+ break;
9577
+ case "similar":
9578
+ v.color = COLORS.edgeSimilar;
9579
+ v.dashes = false;
9580
+ v.width = 1 + Math.round((edge.confidence ?? 0) * 3);
9581
+ break;
9582
+ default:
9583
+ v.color = COLORS.edgeDefault;
9584
+ break;
9585
+ }
9586
+ return v;
9587
+ }
9588
+ function bfsFromTicket(snap, ticketId, depth) {
9589
+ const nodeIds = new Set([ticketId]);
9590
+ const edgeIdxs = new Set;
9591
+ let frontier = new Set([ticketId]);
9592
+ for (let d = 0;d < depth; d++) {
9593
+ const next = new Set;
9594
+ snap.edges.forEach((e, i) => {
9595
+ if (frontier.has(e.from) && !nodeIds.has(e.to)) {
9596
+ nodeIds.add(e.to);
9597
+ next.add(e.to);
9598
+ edgeIdxs.add(i);
9599
+ } else if (frontier.has(e.to) && !nodeIds.has(e.from)) {
9600
+ nodeIds.add(e.from);
9601
+ next.add(e.from);
9602
+ edgeIdxs.add(i);
9603
+ } else if (frontier.has(e.from) && nodeIds.has(e.to)) {
9604
+ edgeIdxs.add(i);
9605
+ } else if (frontier.has(e.to) && nodeIds.has(e.from)) {
9606
+ edgeIdxs.add(i);
9607
+ }
9608
+ });
9609
+ frontier = next;
9610
+ if (frontier.size === 0)
9611
+ break;
9612
+ }
9613
+ return { nodeIds, edgeIdxs };
9614
+ }
9615
+ function transformForVisNetwork(snap, opts) {
9616
+ const mode = opts.performanceMode ?? "full";
9617
+ const nodes = [];
9618
+ const edges = [];
9619
+ let includeTickets = new Set;
9620
+ let includeScenarios = new Set;
9621
+ let includePoms = new Set;
9622
+ let includeAreas = new Set;
9623
+ let includeEdgeIdxs = new Set;
9624
+ if (opts.ticketId) {
9625
+ const result = bfsFromTicket(snap, opts.ticketId, opts.depth ?? 2);
9626
+ for (const id of result.nodeIds) {
9627
+ if (snap.tickets[id])
9628
+ includeTickets.add(id);
9629
+ else if (snap.scenarios[id])
9630
+ includeScenarios.add(id);
9631
+ else if (snap.poms[id])
9632
+ includePoms.add(id);
9633
+ else if (snap.areas[id])
9634
+ includeAreas.add(id);
9635
+ }
9636
+ includeEdgeIdxs = result.edgeIdxs;
9637
+ } else {
9638
+ includeTickets = new Set(Object.keys(snap.tickets).filter((id) => ticketsAfter(opts.since, snap.tickets[id].fetchedAt)));
9639
+ includeScenarios = new Set(Object.keys(snap.scenarios).filter((id) => scenariosAfter(opts.since, snap.scenarios[id].generatedAt)));
9640
+ includePoms = new Set(Object.keys(snap.poms));
9641
+ includeAreas = new Set(Object.keys(snap.areas));
9642
+ snap.edges.forEach((_, i) => {
9643
+ includeEdgeIdxs.add(i);
9644
+ });
9645
+ }
9646
+ if (mode === "ticket-only") {
9647
+ includeScenarios.clear();
9648
+ includePoms.clear();
9649
+ includeAreas.clear();
9650
+ }
9651
+ for (const id of includeTickets)
9652
+ nodes.push(buildTicketNode(snap, id));
9653
+ for (const id of includeScenarios)
9654
+ nodes.push(buildScenarioNode(snap, id));
9655
+ for (const id of includePoms)
9656
+ nodes.push(buildPomNode(snap, id));
9657
+ for (const id of includeAreas)
9658
+ nodes.push(buildAreaNode(snap, id));
9659
+ for (const failure of Object.values(snap.latest_failures)) {
9660
+ if (includeScenarios.has(failure.scenarioId)) {
9661
+ nodes.push(buildFailureNode(snap, failure));
9662
+ }
9663
+ }
9664
+ const visibleNodeIds = new Set(nodes.map((n) => n.id));
9665
+ for (const i of includeEdgeIdxs) {
9666
+ const e = snap.edges[i];
9667
+ if (!e)
9668
+ continue;
9669
+ if (!visibleNodeIds.has(e.from) || !visibleNodeIds.has(e.to))
9670
+ continue;
9671
+ edges.push(buildEdge(e, i));
9672
+ }
9673
+ const stats = {
9674
+ tickets: includeTickets.size,
9675
+ scenarios: includeScenarios.size,
9676
+ poms: includePoms.size,
9677
+ areas: includeAreas.size,
9678
+ failures: nodes.filter((n) => n.group === "Failure").length,
9679
+ edges: edges.length
9680
+ };
9681
+ return { nodes, edges, stats };
9682
+ }
9683
+ var __filename2 = fileURLToPath(import.meta.url);
9684
+ var __dirname2 = dirname6(__filename2);
9685
+ var TEMPLATES_DIR = join16(__dirname2, "templates");
9686
+ function loadTemplate(name) {
9687
+ return readFileSync17(join16(TEMPLATES_DIR, name), "utf8");
9688
+ }
9689
+ function statsToHuman(s) {
9690
+ return `${s.tickets} tickets \xB7 ${s.scenarios} scenarios \xB7 ${s.poms} POMs \xB7 ${s.edges} edges`;
9691
+ }
9692
+ function renderHtml(input) {
9693
+ const template = loadTemplate("graph.html.template");
9694
+ const css = loadTemplate("graph.css");
9695
+ const js = loadTemplate("graph.js");
9696
+ const visNetwork = loadTemplate("vis-network.min.js");
9697
+ const graphJson = JSON.stringify(input.data);
9698
+ const statsHuman = statsToHuman(input.stats);
9699
+ return template.replace("{{CSS}}", () => css).replace("{{STATS}}", () => statsHuman).replace("{{GENERATED_AT}}", () => input.generatedAt).replace("{{VIS_NETWORK_JS}}", () => visNetwork).replace("{{GRAPH_DATA}}", () => graphJson).replace("{{INTERACTION_JS}}", () => js);
9700
+ }
9701
+
9702
+ // src/bin-internal/graph-render.ts
9703
+ init_store();
9704
+ function parseDepth(s) {
9705
+ const n = s ? Number.parseInt(s, 10) : 2;
9706
+ if (n === 1 || n === 3)
9707
+ return n;
9708
+ return 2;
9709
+ }
9710
+ function decidePerformanceMode(nodeCount) {
9711
+ if (nodeCount > 2000)
9712
+ return "text-fallback";
9713
+ if (nodeCount > 500)
9714
+ return "ticket-only";
9715
+ return "full";
9716
+ }
9717
+ async function graphRenderCmd(argv) {
9718
+ let outPath;
9719
+ let ticketId;
9720
+ let since;
9721
+ let depth = 2;
9722
+ for (let i = 0;i < argv.length; i++) {
9723
+ if (argv[i] === "--out")
9724
+ outPath = argv[++i];
9725
+ else if (argv[i] === "--ticket")
9726
+ ticketId = argv[++i];
9727
+ else if (argv[i] === "--since")
9728
+ since = argv[++i];
9729
+ else if (argv[i] === "--depth")
9730
+ depth = parseDepth(argv[++i]);
9731
+ }
9732
+ const repoRoot = process.cwd();
9733
+ const finalPath = outPath ?? join17(repoRoot, ".xera/graph.html");
9734
+ const snap = deriveSnapshot(loadAllEvents(repoRoot));
9735
+ const totalNodeCount = Object.keys(snap.tickets).length + Object.keys(snap.scenarios).length + Object.keys(snap.poms).length + Object.keys(snap.areas).length;
9736
+ const performanceMode = decidePerformanceMode(totalNodeCount);
9737
+ if (performanceMode === "text-fallback") {
9738
+ const txtPath = finalPath.replace(/\.html$/, ".txt");
9739
+ mkdirSync11(dirname7(txtPath), { recursive: true });
9740
+ writeFileSync10(txtPath, `Graph too large for HTML viewer (${totalNodeCount} nodes). Use 'xera:graph-query --format text' instead.
9741
+ `);
9742
+ console.log(`[graph-render] graph too large (${totalNodeCount} nodes); wrote ${txtPath}`);
9743
+ return 0;
9744
+ }
9745
+ const opts = { depth, performanceMode };
9746
+ if (ticketId)
9747
+ opts.ticketId = ticketId;
9748
+ if (since)
9749
+ opts.since = since;
9750
+ const data = transformForVisNetwork(snap, opts);
9751
+ const html = renderHtml({ data, stats: data.stats, generatedAt: new Date().toISOString() });
9752
+ mkdirSync11(dirname7(finalPath), { recursive: true });
9753
+ const tmpPath = `${finalPath}.tmp`;
9754
+ writeFileSync10(tmpPath, html);
9755
+ renameSync2(tmpPath, finalPath);
9756
+ console.log(`[graph-render] wrote ${finalPath} (${data.stats.tickets} tickets \xB7 ${data.stats.scenarios} scenarios \xB7 ${html.length} bytes)`);
9757
+ return 0;
9758
+ }
9759
+
9453
9760
  // src/bin-internal/graph-snapshot.ts
9454
9761
  init_store();
9455
9762
  async function graphSnapshotCmd(argv) {
@@ -9475,8 +9782,8 @@ async function graphSnapshotCmd(argv) {
9475
9782
  }
9476
9783
 
9477
9784
  // src/bin-internal/heal-prepare.ts
9478
- import { existsSync as existsSync20, readdirSync as readdirSync6, readFileSync as readFileSync17, writeFileSync as writeFileSync10 } from "fs";
9479
- import { join as join16 } from "path";
9785
+ import { existsSync as existsSync20, readdirSync as readdirSync6, readFileSync as readFileSync18, writeFileSync as writeFileSync11 } from "fs";
9786
+ import { join as join18 } from "path";
9480
9787
  import { scrubFreeText } from "@xera-ai/web";
9481
9788
 
9482
9789
  // ../../node_modules/.bun/fflate@0.8.3/node_modules/fflate/esm/index.mjs
@@ -9906,7 +10213,7 @@ function classifyKind(raw) {
9906
10213
  function extractDomSnapshot(tracePath) {
9907
10214
  if (!existsSync20(tracePath))
9908
10215
  return "";
9909
- const buf = readFileSync17(tracePath);
10216
+ const buf = readFileSync18(tracePath);
9910
10217
  const entries = unzipSync(buf);
9911
10218
  const traceKey = Object.keys(entries).find((name) => name.endsWith(".trace"));
9912
10219
  let chosenKey = null;
@@ -9954,16 +10261,16 @@ function extractDomSnapshot(tracePath) {
9954
10261
  return scrubFreeText(html);
9955
10262
  }
9956
10263
  function findPomLine(ticketDir, rawLocator) {
9957
- const pomDir = join16(ticketDir, "page-objects");
10264
+ const pomDir = join18(ticketDir, "page-objects");
9958
10265
  const candidates = [];
9959
10266
  if (existsSync20(pomDir)) {
9960
10267
  for (const name of readdirSync6(pomDir)) {
9961
10268
  if (name.endsWith(".ts"))
9962
- candidates.push(join16(pomDir, name));
10269
+ candidates.push(join18(pomDir, name));
9963
10270
  }
9964
10271
  }
9965
10272
  for (const file of candidates) {
9966
- const text = readFileSync17(file, "utf8");
10273
+ const text = readFileSync18(file, "utf8");
9967
10274
  const lines = text.split(`
9968
10275
  `);
9969
10276
  for (let i2 = 0;i2 < lines.length; i2++) {
@@ -10001,13 +10308,13 @@ function findGherkinStep(featureText, rawLocator) {
10001
10308
  }
10002
10309
  function healPrepare(repoRoot, ticket, runId, scenarioName) {
10003
10310
  const paths = resolveArtifactPaths(repoRoot, ticket);
10004
- const classifierPath = join16(paths.ticketDir, "classifier-input.json");
10005
- const classifier = JSON.parse(readFileSync17(classifierPath, "utf8"));
10311
+ const classifierPath = join18(paths.ticketDir, "classifier-input.json");
10312
+ const classifier = JSON.parse(readFileSync18(classifierPath, "utf8"));
10006
10313
  const cls = classifier.scenarios.find((s) => s.name === scenarioName);
10007
10314
  if (!cls)
10008
10315
  throw new Error(`scenario not found in classifier-input: "${scenarioName}"`);
10009
- const runDir = join16(paths.runsDir, runId);
10010
- const normalized = JSON.parse(readFileSync17(join16(runDir, "normalized.json"), "utf8"));
10316
+ const runDir = join18(paths.runsDir, runId);
10317
+ const normalized = JSON.parse(readFileSync18(join18(runDir, "normalized.json"), "utf8"));
10011
10318
  const normSc = normalized.scenarios.find((s) => s.name === scenarioName);
10012
10319
  if (!normSc?.failure)
10013
10320
  throw new Error(`no failure recorded for scenario "${scenarioName}"`);
@@ -10018,9 +10325,9 @@ function healPrepare(repoRoot, ticket, runId, scenarioName) {
10018
10325
  const raw = m[1].trim();
10019
10326
  const kind = classifyKind(raw);
10020
10327
  const pomLoc = findPomLine(paths.ticketDir, raw);
10021
- const featureText = readFileSync17(paths.featurePath, "utf8");
10328
+ const featureText = readFileSync18(paths.featurePath, "utf8");
10022
10329
  const gherkinStep = findGherkinStep(featureText, raw);
10023
- const domSnapshotAtFailure = extractDomSnapshot(join16(runDir, "trace.zip"));
10330
+ const domSnapshotAtFailure = extractDomSnapshot(join18(runDir, "trace.zip"));
10024
10331
  return {
10025
10332
  ticket,
10026
10333
  runId,
@@ -10040,8 +10347,8 @@ async function healPrepareCmd(argv) {
10040
10347
  try {
10041
10348
  const result = healPrepare(process.cwd(), ticket, runId, scenarioName);
10042
10349
  const paths = resolveArtifactPaths(process.cwd(), ticket);
10043
- const outPath = join16(paths.runsDir, runId, "heal-input.json");
10044
- writeFileSync10(outPath, JSON.stringify(result, null, 2));
10350
+ const outPath = join18(paths.runsDir, runId, "heal-input.json");
10351
+ writeFileSync11(outPath, JSON.stringify(result, null, 2));
10045
10352
  console.log(`[xera:heal-prepare] wrote ${outPath}`);
10046
10353
  return 0;
10047
10354
  } catch (err2) {
@@ -10050,6 +10357,267 @@ async function healPrepareCmd(argv) {
10050
10357
  }
10051
10358
  }
10052
10359
 
10360
+ // src/bin-internal/impact-prepare.ts
10361
+ import { mkdirSync as mkdirSync12, writeFileSync as writeFileSync12 } from "fs";
10362
+ import { join as join19 } from "path";
10363
+
10364
+ // src/graph/impact.ts
10365
+ var PRIORITY_WEIGHT = { p0: 3, p1: 2, p2: 1 };
10366
+ var EDGE_WEIGHT_FIXED = {
10367
+ modifies: 5,
10368
+ uses: 4,
10369
+ covers: 4
10370
+ };
10371
+ function jiraRelationWeight(source) {
10372
+ if (!source)
10373
+ return 0;
10374
+ if (source.endsWith("blocks"))
10375
+ return 4;
10376
+ if (source.endsWith("duplicates"))
10377
+ return 3;
10378
+ if (source.endsWith("relates"))
10379
+ return 2;
10380
+ if (source.endsWith("supersedes"))
10381
+ return 3;
10382
+ return 1;
10383
+ }
10384
+ function edgeWeight(edge) {
10385
+ if (edge.kind === "modifies")
10386
+ return EDGE_WEIGHT_FIXED.modifies ?? 0;
10387
+ if (edge.kind === "uses" || edge.kind === "covers")
10388
+ return EDGE_WEIGHT_FIXED.uses ?? 0;
10389
+ if (edge.kind === "jira-linked")
10390
+ return jiraRelationWeight(edge.source);
10391
+ if (edge.kind === "similar")
10392
+ return 1 * (edge.confidence ?? 0);
10393
+ return 0;
10394
+ }
10395
+ function riskScore(scenario, daysSinceLastPass) {
10396
+ const pri = PRIORITY_WEIGHT[scenario.priority] * 3;
10397
+ const firstEdge = scenario.edgePath[0];
10398
+ const edgeW = firstEdge ? edgeWeight(firstEdge) : 0;
10399
+ const confW = firstEdge?.confidence !== undefined ? firstEdge.confidence * 2 : 0;
10400
+ const decay = daysSinceLastPass * 0.1;
10401
+ return pri + edgeW + confW - decay;
10402
+ }
10403
+ var PRIORITY_RANK = { p0: 3, p1: 2, p2: 1 };
10404
+ function daysSince(ts) {
10405
+ if (!ts)
10406
+ return 0;
10407
+ const ms = Date.now() - Date.parse(ts);
10408
+ return ms < 0 ? 0 : ms / (86400 * 1000);
10409
+ }
10410
+ function walkImpact(graph, target, opts) {
10411
+ const result = [];
10412
+ const seen = new Set;
10413
+ const targetAreas = new Set(target.modifiesAreas);
10414
+ const pomIds = graph.edges.filter((e) => e.kind === "covers" && targetAreas.has(e.to)).map((e) => e.from);
10415
+ const directScenarios = graph.edges.filter((e) => e.kind === "uses" && pomIds.includes(e.to)).map((e) => e.from);
10416
+ for (const scenarioId2 of directScenarios) {
10417
+ if (seen.has(scenarioId2))
10418
+ continue;
10419
+ const scenario = graph.scenarios[scenarioId2];
10420
+ if (!scenario)
10421
+ continue;
10422
+ if (scenario.ticketId === target.id)
10423
+ continue;
10424
+ const usingPom = graph.edges.find((e) => e.kind === "uses" && e.from === scenarioId2);
10425
+ const modifyEdge = graph.edges.find((e) => e.kind === "modifies" && e.from === target.id && targetAreas.has(e.to));
10426
+ const edgePath = [];
10427
+ if (modifyEdge)
10428
+ edgePath.push({ kind: "modifies", from: modifyEdge.from, to: modifyEdge.to });
10429
+ if (usingPom)
10430
+ edgePath.push({ kind: "uses", from: usingPom.from, to: usingPom.to });
10431
+ seen.add(scenarioId2);
10432
+ const impact = {
10433
+ scenarioId: scenarioId2,
10434
+ ticketId: scenario.ticketId,
10435
+ name: scenario.name,
10436
+ priority: scenario.priority,
10437
+ edgePath,
10438
+ riskScore: 0
10439
+ };
10440
+ impact.riskScore = riskScore(impact, daysSince(graph.latest_failures[scenarioId2]?.ts));
10441
+ result.push(impact);
10442
+ }
10443
+ if (opts.depth >= 2) {
10444
+ const linked = graph.edges.filter((e) => e.kind === "jira-linked" && e.from === target.id).map((e) => ({ to: e.to, source: e.source }));
10445
+ for (const link of linked) {
10446
+ const sceneIds = graph.edges.filter((e) => e.kind === "tests" && e.from === link.to).map((e) => e.to);
10447
+ for (const scenarioId2 of sceneIds) {
10448
+ if (seen.has(scenarioId2))
10449
+ continue;
10450
+ const scenario = graph.scenarios[scenarioId2];
10451
+ if (!scenario || scenario.ticketId === target.id)
10452
+ continue;
10453
+ seen.add(scenarioId2);
10454
+ const edge = { kind: "jira-linked", from: target.id, to: link.to };
10455
+ if (link.source !== undefined)
10456
+ edge.source = link.source;
10457
+ const impact = {
10458
+ scenarioId: scenarioId2,
10459
+ ticketId: scenario.ticketId,
10460
+ name: scenario.name,
10461
+ priority: scenario.priority,
10462
+ edgePath: [edge],
10463
+ riskScore: 0
10464
+ };
10465
+ impact.riskScore = riskScore(impact, daysSince(graph.latest_failures[scenarioId2]?.ts));
10466
+ result.push(impact);
10467
+ }
10468
+ }
10469
+ }
10470
+ if (opts.depth >= 3) {
10471
+ const similar = graph.edges.filter((e) => e.kind === "similar" && e.from === target.id).map((e) => ({ to: e.to, confidence: e.confidence }));
10472
+ for (const link of similar) {
10473
+ const sceneIds = graph.edges.filter((e) => e.kind === "tests" && e.from === link.to).map((e) => e.to);
10474
+ for (const scenarioId2 of sceneIds) {
10475
+ if (seen.has(scenarioId2))
10476
+ continue;
10477
+ const scenario = graph.scenarios[scenarioId2];
10478
+ if (!scenario || scenario.ticketId === target.id)
10479
+ continue;
10480
+ seen.add(scenarioId2);
10481
+ const edge = { kind: "similar", from: target.id, to: link.to };
10482
+ if (link.confidence !== undefined)
10483
+ edge.confidence = link.confidence;
10484
+ const impact = {
10485
+ scenarioId: scenarioId2,
10486
+ ticketId: scenario.ticketId,
10487
+ name: scenario.name,
10488
+ priority: scenario.priority,
10489
+ edgePath: [edge],
10490
+ riskScore: 0
10491
+ };
10492
+ impact.riskScore = riskScore(impact, daysSince(graph.latest_failures[scenarioId2]?.ts));
10493
+ result.push(impact);
10494
+ }
10495
+ }
10496
+ }
10497
+ let filtered = result;
10498
+ if (opts.minPriority) {
10499
+ const min = PRIORITY_RANK[opts.minPriority];
10500
+ filtered = filtered.filter((s) => PRIORITY_RANK[s.priority] >= min);
10501
+ }
10502
+ filtered.sort((a, b) => b.riskScore - a.riskScore);
10503
+ return filtered;
10504
+ }
10505
+ var HIGH_THRESHOLD = 7;
10506
+ var MEDIUM_THRESHOLD = 4;
10507
+ function bucket(score) {
10508
+ if (score >= HIGH_THRESHOLD)
10509
+ return "high";
10510
+ if (score >= MEDIUM_THRESHOLD)
10511
+ return "medium";
10512
+ return "low";
10513
+ }
10514
+ function fmtEdgePath(path) {
10515
+ return path.map((e) => `${e.from} \u2192[${e.kind}]\u2192 ${e.to}`).join(" \xB7 ");
10516
+ }
10517
+ function renderImpactMarkdown(report) {
10518
+ const lines = [];
10519
+ lines.push(`# Impact Analysis \u2014 ${report.targetTicket}`);
10520
+ lines.push("");
10521
+ lines.push(`**Modified areas:** ${report.modifiedAreas.join(", ") || "(none)"}`);
10522
+ lines.push(`**Generated:** ${report.generatedAt}`);
10523
+ lines.push("");
10524
+ if (report.scenarios.length === 0) {
10525
+ lines.push("No prior scenarios in the modified areas. This may be a new feature area.");
10526
+ lines.push("");
10527
+ return lines.join(`
10528
+ `);
10529
+ }
10530
+ const bySeverity = {
10531
+ high: [],
10532
+ medium: [],
10533
+ low: []
10534
+ };
10535
+ for (const s of report.scenarios)
10536
+ bySeverity[bucket(s.riskScore)].push(s);
10537
+ lines.push(`**Total impacted:** ${report.scenarios.length} scenarios (${bySeverity.high.length} high \xB7 ${bySeverity.medium.length} medium \xB7 ${bySeverity.low.length} low)`);
10538
+ lines.push("");
10539
+ for (const [name, scenarios] of [
10540
+ ["High-risk", bySeverity.high],
10541
+ ["Medium-risk", bySeverity.medium],
10542
+ ["Low-risk", bySeverity.low]
10543
+ ]) {
10544
+ if (scenarios.length === 0)
10545
+ continue;
10546
+ lines.push(`## ${name}`);
10547
+ lines.push("");
10548
+ for (const s of scenarios) {
10549
+ lines.push(`### ${s.ticketId} / "${s.name}" [${s.priority.toUpperCase()}] score ${s.riskScore.toFixed(1)}`);
10550
+ lines.push(`- Edge: ${fmtEdgePath(s.edgePath)}`);
10551
+ if (s.lastPassedAt)
10552
+ lines.push(`- Last passed: ${s.lastPassedAt}`);
10553
+ lines.push("");
10554
+ }
10555
+ }
10556
+ lines.push("## Re-run commands");
10557
+ lines.push(`- All: \`bun run xera:exec --from-impact ${report.targetTicket}\``);
10558
+ lines.push(`- P0 only: \`bun run xera:exec --from-impact ${report.targetTicket} --min-priority p0\``);
10559
+ lines.push(`- Select: \`bun run xera:exec --from-impact ${report.targetTicket} --select\``);
10560
+ lines.push("");
10561
+ return lines.join(`
10562
+ `);
10563
+ }
10564
+
10565
+ // src/bin-internal/impact-prepare.ts
10566
+ init_store();
10567
+ function parseDepth2(s) {
10568
+ const n = s ? Number.parseInt(s, 10) : 2;
10569
+ if (n === 1 || n === 3)
10570
+ return n;
10571
+ return 2;
10572
+ }
10573
+ function parseMinPriority(s) {
10574
+ if (s === "p0" || s === "p1" || s === "p2")
10575
+ return s;
10576
+ return;
10577
+ }
10578
+ async function impactPrepareCmd(argv) {
10579
+ const ticket = argv[0];
10580
+ if (!ticket || ticket.startsWith("--")) {
10581
+ console.error("[impact-prepare] usage: impact-prepare <TICKET> [--depth 1|2|3] [--min-priority p0|p1|p2] [--quiet]");
10582
+ return 1;
10583
+ }
10584
+ let depth = 2;
10585
+ let minPriority;
10586
+ let quiet = false;
10587
+ for (let i2 = 1;i2 < argv.length; i2++) {
10588
+ if (argv[i2] === "--depth")
10589
+ depth = parseDepth2(argv[++i2]);
10590
+ else if (argv[i2] === "--min-priority")
10591
+ minPriority = parseMinPriority(argv[++i2]);
10592
+ else if (argv[i2] === "--quiet")
10593
+ quiet = true;
10594
+ }
10595
+ const repoRoot = process.cwd();
10596
+ const graph = deriveSnapshot(loadAllEvents(repoRoot));
10597
+ const target = graph.tickets[ticket];
10598
+ if (!target) {
10599
+ console.error(`[impact-prepare] ticket ${ticket} not in graph; run /xera-fetch first`);
10600
+ return 2;
10601
+ }
10602
+ const opts = { depth };
10603
+ if (minPriority)
10604
+ opts.minPriority = minPriority;
10605
+ const scenarios = walkImpact(graph, target, opts);
10606
+ const report = {
10607
+ targetTicket: ticket,
10608
+ modifiedAreas: target.modifiesAreas,
10609
+ scenarios,
10610
+ generatedAt: new Date().toISOString()
10611
+ };
10612
+ const impactDir = join19(repoRoot, ".xera/impact");
10613
+ mkdirSync12(impactDir, { recursive: true });
10614
+ writeFileSync12(join19(impactDir, `${ticket}.json`), JSON.stringify(report, null, 2));
10615
+ if (!quiet) {
10616
+ writeFileSync12(join19(impactDir, `${ticket}.md`), renderImpactMarkdown(report));
10617
+ }
10618
+ return 0;
10619
+ }
10620
+
10053
10621
  // src/bin-internal/lint.ts
10054
10622
  import { lintTicket } from "@xera-ai/web";
10055
10623
  async function lintCmd(argv) {
@@ -10071,7 +10639,7 @@ async function lintCmd(argv) {
10071
10639
 
10072
10640
  // src/bin-internal/normalize.ts
10073
10641
  import { existsSync as existsSync21, readdirSync as readdirSync7 } from "fs";
10074
- import { join as join17 } from "path";
10642
+ import { join as join20 } from "path";
10075
10643
  import { normalizeRun } from "@xera-ai/web";
10076
10644
  async function normalizeCmd(argv) {
10077
10645
  const ticket = argv[0];
@@ -10086,7 +10654,7 @@ async function normalizeCmd(argv) {
10086
10654
  console.error("[xera:normalize] no run found");
10087
10655
  return 1;
10088
10656
  }
10089
- const runDir = join17(paths.runsDir, runId);
10657
+ const runDir = join20(paths.runsDir, runId);
10090
10658
  if (!existsSync21(runDir)) {
10091
10659
  console.error(`[xera:normalize] runs/${runId} missing`);
10092
10660
  return 1;
@@ -10097,12 +10665,12 @@ async function normalizeCmd(argv) {
10097
10665
  }
10098
10666
 
10099
10667
  // src/bin-internal/post.ts
10100
- import { existsSync as existsSync23, readFileSync as readFileSync19 } from "fs";
10101
- import { join as join18 } from "path";
10668
+ import { existsSync as existsSync23, readFileSync as readFileSync20 } from "fs";
10669
+ import { join as join21 } from "path";
10102
10670
 
10103
10671
  // src/artifact/status.ts
10104
- import { existsSync as existsSync22, mkdirSync as mkdirSync11, readFileSync as readFileSync18, writeFileSync as writeFileSync11 } from "fs";
10105
- import { dirname as dirname6 } from "path";
10672
+ import { existsSync as existsSync22, mkdirSync as mkdirSync13, readFileSync as readFileSync19, writeFileSync as writeFileSync13 } from "fs";
10673
+ import { dirname as dirname8 } from "path";
10106
10674
  import { z as z7 } from "zod";
10107
10675
  var ClassificationEnum = z7.enum([
10108
10676
  "PASS",
@@ -10138,11 +10706,11 @@ var HISTORY_CAP = 20;
10138
10706
  function readStatus(path) {
10139
10707
  if (!existsSync22(path))
10140
10708
  return null;
10141
- return StatusJsonSchema.parse(JSON.parse(readFileSync18(path, "utf8")));
10709
+ return StatusJsonSchema.parse(JSON.parse(readFileSync19(path, "utf8")));
10142
10710
  }
10143
10711
  function writeStatus(path, status) {
10144
- mkdirSync11(dirname6(path), { recursive: true });
10145
- writeFileSync11(path, JSON.stringify(status, null, 2));
10712
+ mkdirSync13(dirname8(path), { recursive: true });
10713
+ writeFileSync13(path, JSON.stringify(status, null, 2));
10146
10714
  }
10147
10715
  function appendHistory(path, entry) {
10148
10716
  const s = readStatus(path);
@@ -10168,12 +10736,12 @@ async function postCmd(argv) {
10168
10736
  return 0;
10169
10737
  }
10170
10738
  const paths = resolveArtifactPaths(cwd, ticket);
10171
- const draftPath = join18(paths.ticketDir, "jira-comment.draft.md");
10739
+ const draftPath = join21(paths.ticketDir, "jira-comment.draft.md");
10172
10740
  if (!existsSync23(draftPath)) {
10173
10741
  console.error(`[xera:post] no draft at ${draftPath}; run \`xera-internal report\` first.`);
10174
10742
  return 1;
10175
10743
  }
10176
- const body = readFileSync19(draftPath, "utf8");
10744
+ const body = readFileSync20(draftPath, "utf8");
10177
10745
  const client = await createJiraClient({
10178
10746
  baseUrl: config.jira.baseUrl,
10179
10747
  preferMcp: true,
@@ -10201,8 +10769,8 @@ async function promoteCmd(argv) {
10201
10769
  }
10202
10770
 
10203
10771
  // src/bin-internal/report.ts
10204
- import { existsSync as existsSync25, readFileSync as readFileSync20, writeFileSync as writeFileSync12 } from "fs";
10205
- import { join as join19 } from "path";
10772
+ import { existsSync as existsSync25, readFileSync as readFileSync21, writeFileSync as writeFileSync14 } from "fs";
10773
+ import { join as join22 } from "path";
10206
10774
 
10207
10775
  // src/classifier/aggregate.ts
10208
10776
  var CLASS_PRIORITY = [
@@ -10374,10 +10942,10 @@ async function reportCmd(argv) {
10374
10942
  return 1;
10375
10943
  }
10376
10944
  const paths = resolveArtifactPaths(process.cwd(), ticket);
10377
- const input = JSON.parse(readFileSync20(inputArg.slice("--input=".length), "utf8"));
10945
+ const input = JSON.parse(readFileSync21(inputArg.slice("--input=".length), "utf8"));
10378
10946
  const aggregated = aggregateScenarios(input.scenarios);
10379
- const decisionsPath = join19(paths.ticketDir, "runs", input.runId, "outdated-decisions.json");
10380
- const decisions = existsSync25(decisionsPath) ? JSON.parse(readFileSync20(decisionsPath, "utf8")) : {};
10947
+ const decisionsPath = join22(paths.ticketDir, "runs", input.runId, "outdated-decisions.json");
10948
+ const decisions = existsSync25(decisionsPath) ? JSON.parse(readFileSync21(decisionsPath, "utf8")) : {};
10381
10949
  const graph = deriveSnapshot(loadAllEvents(process.cwd()));
10382
10950
  const normalizeScenarioName = (name) => name.trim().toLowerCase().replace(/\s+/g, " ");
10383
10951
  const scenarioIdByName = {};
@@ -10425,8 +10993,8 @@ async function reportCmd(argv) {
10425
10993
  xeraVersion: "0.1.0",
10426
10994
  promptsVersion: "1.0.0"
10427
10995
  });
10428
- const draftPath = join19(paths.ticketDir, "jira-comment.draft.md");
10429
- writeFileSync12(draftPath, md);
10996
+ const draftPath = join22(paths.ticketDir, "jira-comment.draft.md");
10997
+ writeFileSync14(draftPath, md);
10430
10998
  console.log(`[xera:report] wrote status.json and ${draftPath}`);
10431
10999
  return 0;
10432
11000
  }
@@ -10493,7 +11061,7 @@ async function unlockCmd(argv) {
10493
11061
  }
10494
11062
 
10495
11063
  // src/bin-internal/validate-feature.ts
10496
- import { existsSync as existsSync26, readFileSync as readFileSync21 } from "fs";
11064
+ import { existsSync as existsSync26, readFileSync as readFileSync22 } from "fs";
10497
11065
  import { validateGherkin as validateGherkin2 } from "@xera-ai/web";
10498
11066
  async function validateFeatureCmd(argv) {
10499
11067
  const ticket = argv[0];
@@ -10506,7 +11074,7 @@ async function validateFeatureCmd(argv) {
10506
11074
  console.error(`[xera:validate-feature] missing ${paths.featurePath}`);
10507
11075
  return 1;
10508
11076
  }
10509
- const r = validateGherkin2(readFileSync21(paths.featurePath, "utf8"));
11077
+ const r = validateGherkin2(readFileSync22(paths.featurePath, "utf8"));
10510
11078
  if (r.ok) {
10511
11079
  console.log("[xera:validate-feature] ok");
10512
11080
  return 0;
@@ -10526,10 +11094,12 @@ var COMMANDS = {
10526
11094
  fetch: fetchCmd,
10527
11095
  "graph-backfill": graphBackfillCmd,
10528
11096
  "graph-enrich": graphEnrichCmd,
11097
+ "graph-render": graphRenderCmd,
10529
11098
  "graph-query": graphQueryCmd,
10530
11099
  "graph-record": graphRecordCmd,
10531
11100
  "graph-snapshot": graphSnapshotCmd,
10532
11101
  "heal-prepare": healPrepareCmd,
11102
+ "impact-prepare": impactPrepareCmd,
10533
11103
  lint: lintCmd,
10534
11104
  normalize: normalizeCmd,
10535
11105
  post: postCmd,