@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.
@@ -0,0 +1,2 @@
1
+ export declare function graphRenderCmd(argv: string[]): Promise<number>;
2
+ //# sourceMappingURL=graph-render.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"graph-render.d.ts","sourceRoot":"","sources":["../../src/bin-internal/graph-render.ts"],"names":[],"mappings":"AAkBA,wBAAsB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAmDpE"}
@@ -0,0 +1,2 @@
1
+ export declare function impactPrepareCmd(argv: string[]): Promise<number>;
2
+ //# sourceMappingURL=impact-prepare.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"impact-prepare.d.ts","sourceRoot":"","sources":["../../src/bin-internal/impact-prepare.ts"],"names":[],"mappings":"AAkBA,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CA6CtE"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/bin-internal/index.ts"],"names":[],"mappings":"AAgDA,wBAAsB,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAczD"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/bin-internal/index.ts"],"names":[],"mappings":"AAoDA,wBAAsB,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAczD"}
@@ -60,6 +60,12 @@ export declare const XeraConfigSchema: z.ZodObject<{
60
60
  local: "local";
61
61
  }>>;
62
62
  }, z.core.$strip>>;
63
+ run: z.ZodPrefault<z.ZodPrefault<z.ZodObject<{
64
+ autoImpact: z.ZodPrefault<z.ZodObject<{
65
+ enabled: z.ZodDefault<z.ZodBoolean>;
66
+ threshold: z.ZodDefault<z.ZodNumber>;
67
+ }, z.core.$strip>>;
68
+ }, z.core.$strip>>>;
63
69
  adapters: z.ZodDefault<z.ZodArray<z.ZodString>>;
64
70
  }, z.core.$strip>;
65
71
  export type XeraConfig = z.infer<typeof XeraConfigSchema>;
@@ -1 +1 @@
1
- {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/config/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAuExB,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAM3B,CAAC;AAEH,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC"}
1
+ {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/config/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAkFxB,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAO3B,CAAC;AAEH,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC"}
@@ -0,0 +1,31 @@
1
+ import type { EdgeKind, Priority, Snapshot, TicketNode } from './types';
2
+ export interface ImpactEdge {
3
+ kind: EdgeKind;
4
+ from: string;
5
+ to: string;
6
+ confidence?: number;
7
+ source?: string;
8
+ }
9
+ export interface ImpactScenario {
10
+ scenarioId: string;
11
+ ticketId: string;
12
+ name: string;
13
+ priority: Priority;
14
+ edgePath: ImpactEdge[];
15
+ riskScore: number;
16
+ lastPassedAt?: string;
17
+ }
18
+ export interface ImpactOpts {
19
+ depth: 1 | 2 | 3;
20
+ minPriority?: Priority;
21
+ }
22
+ export interface ImpactReport {
23
+ targetTicket: string;
24
+ modifiedAreas: string[];
25
+ scenarios: ImpactScenario[];
26
+ generatedAt: string;
27
+ }
28
+ export declare function riskScore(scenario: ImpactScenario, daysSinceLastPass: number): number;
29
+ export declare function walkImpact(graph: Snapshot, target: TicketNode, opts: ImpactOpts): ImpactScenario[];
30
+ export declare function renderImpactMarkdown(report: ImpactReport): string;
31
+ //# sourceMappingURL=impact.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"impact.d.ts","sourceRoot":"","sources":["../../src/graph/impact.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AAExE,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,QAAQ,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,cAAc;IAC7B,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,QAAQ,CAAC;IACnB,QAAQ,EAAE,UAAU,EAAE,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IACjB,WAAW,CAAC,EAAE,QAAQ,CAAC;CACxB;AAED,MAAM,WAAW,YAAY;IAC3B,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,SAAS,EAAE,cAAc,EAAE,CAAC;IAC5B,WAAW,EAAE,MAAM,CAAC;CACrB;AA4BD,wBAAgB,SAAS,CAAC,QAAQ,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,GAAG,MAAM,CAOrF;AAUD,wBAAgB,UAAU,CACxB,KAAK,EAAE,QAAQ,EACf,MAAM,EAAE,UAAU,EAClB,IAAI,EAAE,UAAU,GACf,cAAc,EAAE,CAkHlB;AAeD,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,YAAY,GAAG,MAAM,CAqDjE"}
@@ -4,7 +4,11 @@ export type { CostSummary, LlmCallLog } from './cost';
4
4
  export { logLlmCall, summarizeCost } from './cost';
5
5
  export type { EnrichOptions, EnrichResult } from './enrich';
6
6
  export { enrichTicket } from './enrich';
7
+ export type { ImpactEdge, ImpactOpts, ImpactReport, ImpactScenario, } from './impact';
8
+ export { renderImpactMarkdown, riskScore, walkImpact, } from './impact';
7
9
  export { currentYyyyMm, graphPaths } from './paths';
10
+ export type { GraphStats, RenderHtmlInput, RenderOpts, VisEdge, VisNode } from './render';
11
+ export { renderHtml, transformForVisNetwork } from './render';
8
12
  export { EventSchema, safeParseEvent } from './schema';
9
13
  export { buildSimilarityPrompt } from './similarity';
10
14
  export { appendEvents, computeEventsHash, deriveSnapshot, isSnapshotStale, loadAllEvents, loadSnapshot, writeSnapshot, } from './store';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/graph/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,iBAAiB,EACjB,gBAAgB,EAChB,aAAa,EACb,cAAc,EACd,cAAc,EACd,gBAAgB,GACjB,MAAM,YAAY,CAAC;AACpB,OAAO,EACL,qBAAqB,EACrB,oBAAoB,GACrB,MAAM,YAAY,CAAC;AACpB,YAAY,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,QAAQ,CAAC;AACtD,OAAO,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,QAAQ,CAAC;AACnD,YAAY,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAC5D,OAAO,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AACxC,OAAO,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACpD,OAAO,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AACvD,OAAO,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAC;AACrD,OAAO,EACL,YAAY,EACZ,iBAAiB,EACjB,cAAc,EACd,eAAe,EACf,aAAa,EACb,YAAY,EACZ,aAAa,GACd,MAAM,SAAS,CAAC;AACjB,cAAc,SAAS,CAAC;AACxB,OAAO,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/graph/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,iBAAiB,EACjB,gBAAgB,EAChB,aAAa,EACb,cAAc,EACd,cAAc,EACd,gBAAgB,GACjB,MAAM,YAAY,CAAC;AACpB,OAAO,EACL,qBAAqB,EACrB,oBAAoB,GACrB,MAAM,YAAY,CAAC;AACpB,YAAY,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,QAAQ,CAAC;AACtD,OAAO,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,QAAQ,CAAC;AACnD,YAAY,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAC5D,OAAO,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AACxC,YAAY,EACV,UAAU,EACV,UAAU,EACV,YAAY,EACZ,cAAc,GACf,MAAM,UAAU,CAAC;AAClB,OAAO,EACL,oBAAoB,EACpB,SAAS,EACT,UAAU,GACX,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACpD,YAAY,EAAE,UAAU,EAAE,eAAe,EAAE,UAAU,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,UAAU,CAAC;AAC1F,OAAO,EAAE,UAAU,EAAE,sBAAsB,EAAE,MAAM,UAAU,CAAC;AAC9D,OAAO,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AACvD,OAAO,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAC;AACrD,OAAO,EACL,YAAY,EACZ,iBAAiB,EACjB,cAAc,EACd,eAAe,EACf,aAAa,EACb,YAAY,EACZ,aAAa,GACd,MAAM,SAAS,CAAC;AACjB,cAAc,SAAS,CAAC;AACxB,OAAO,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC"}
@@ -0,0 +1,50 @@
1
+ import type { Snapshot } from './types';
2
+ export interface VisNode {
3
+ id: string;
4
+ label: string;
5
+ group: 'Ticket' | 'Scenario' | 'POM' | 'SUTArea' | 'Failure';
6
+ color?: string;
7
+ shape?: string;
8
+ size?: number;
9
+ title?: string;
10
+ borderWidth?: number;
11
+ }
12
+ export interface VisEdge {
13
+ id?: string;
14
+ from: string;
15
+ to: string;
16
+ label?: string;
17
+ color?: string;
18
+ dashes?: boolean;
19
+ width?: number;
20
+ arrows?: string;
21
+ }
22
+ export interface GraphStats {
23
+ tickets: number;
24
+ scenarios: number;
25
+ poms: number;
26
+ areas: number;
27
+ failures: number;
28
+ edges: number;
29
+ }
30
+ export interface RenderOpts {
31
+ since?: string;
32
+ ticketId?: string;
33
+ depth?: 1 | 2 | 3;
34
+ performanceMode?: 'full' | 'ticket-only' | 'text-fallback';
35
+ }
36
+ export declare function transformForVisNetwork(snap: Snapshot, opts: RenderOpts): {
37
+ nodes: VisNode[];
38
+ edges: VisEdge[];
39
+ stats: GraphStats;
40
+ };
41
+ export interface RenderHtmlInput {
42
+ data: {
43
+ nodes: VisNode[];
44
+ edges: VisEdge[];
45
+ };
46
+ stats: GraphStats;
47
+ generatedAt: string;
48
+ }
49
+ export declare function renderHtml(input: RenderHtmlInput): string;
50
+ //# sourceMappingURL=render.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../../src/graph/render.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAc,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEpD,MAAM,WAAW,OAAO;IACtB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,QAAQ,GAAG,UAAU,GAAG,KAAK,GAAG,SAAS,GAAG,SAAS,CAAC;IAC7D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,OAAO;IACtB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,UAAU;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IAClB,eAAe,CAAC,EAAE,MAAM,GAAG,aAAa,GAAG,eAAe,CAAC;CAC5D;AAmKD,wBAAgB,sBAAsB,CACpC,IAAI,EAAE,QAAQ,EACd,IAAI,EAAE,UAAU,GACf;IACD,KAAK,EAAE,OAAO,EAAE,CAAC;IACjB,KAAK,EAAE,OAAO,EAAE,CAAC;IACjB,KAAK,EAAE,UAAU,CAAC;CACnB,CA4EA;AAcD,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE;QAAE,KAAK,EAAE,OAAO,EAAE,CAAC;QAAC,KAAK,EAAE,OAAO,EAAE,CAAA;KAAE,CAAC;IAC7C,KAAK,EAAE,UAAU,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,wBAAgB,UAAU,CAAC,KAAK,EAAE,eAAe,GAAG,MAAM,CAgBzD"}
package/dist/src/index.js CHANGED
@@ -324,11 +324,18 @@ var ReportingSchema = z4.object({
324
324
  }).prefault({}),
325
325
  artifactLinks: z4.enum(["git", "local"]).default("git")
326
326
  }).prefault({});
327
+ var RunSchema = z4.object({
328
+ autoImpact: z4.object({
329
+ enabled: z4.boolean().default(true),
330
+ threshold: z4.number().nonnegative().default(6)
331
+ }).prefault({})
332
+ }).prefault({});
327
333
  var XeraConfigSchema = z4.object({
328
334
  jira: JiraSchema,
329
335
  web: WebSchema,
330
336
  ai: AISchema,
331
337
  reporting: ReportingSchema,
338
+ run: RunSchema.prefault({}),
332
339
  adapters: z4.array(z4.string().min(1)).min(1).default(["web"])
333
340
  });
334
341
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xera-ai/core",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -0,0 +1,70 @@
1
+ import { mkdirSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import type { RenderOpts } from '../graph/render';
4
+ import { renderHtml, transformForVisNetwork } from '../graph/render';
5
+ import { deriveSnapshot, loadAllEvents } from '../graph/store';
6
+
7
+ function parseDepth(s: string | undefined): 1 | 2 | 3 {
8
+ const n = s ? Number.parseInt(s, 10) : 2;
9
+ if (n === 1 || n === 3) return n;
10
+ return 2;
11
+ }
12
+
13
+ function decidePerformanceMode(nodeCount: number): 'full' | 'ticket-only' | 'text-fallback' {
14
+ if (nodeCount > 2000) return 'text-fallback';
15
+ if (nodeCount > 500) return 'ticket-only';
16
+ return 'full';
17
+ }
18
+
19
+ export async function graphRenderCmd(argv: string[]): Promise<number> {
20
+ let outPath: string | undefined;
21
+ let ticketId: string | undefined;
22
+ let since: string | undefined;
23
+ let depth: 1 | 2 | 3 = 2;
24
+
25
+ for (let i = 0; i < argv.length; i++) {
26
+ if (argv[i] === '--out') outPath = argv[++i];
27
+ else if (argv[i] === '--ticket') ticketId = argv[++i];
28
+ else if (argv[i] === '--since') since = argv[++i];
29
+ else if (argv[i] === '--depth') depth = parseDepth(argv[++i]);
30
+ }
31
+
32
+ const repoRoot = process.cwd();
33
+ const finalPath = outPath ?? join(repoRoot, '.xera/graph.html');
34
+
35
+ const snap = deriveSnapshot(loadAllEvents(repoRoot));
36
+ const totalNodeCount =
37
+ Object.keys(snap.tickets).length +
38
+ Object.keys(snap.scenarios).length +
39
+ Object.keys(snap.poms).length +
40
+ Object.keys(snap.areas).length;
41
+ const performanceMode = decidePerformanceMode(totalNodeCount);
42
+
43
+ if (performanceMode === 'text-fallback') {
44
+ const txtPath = finalPath.replace(/\.html$/, '.txt');
45
+ mkdirSync(dirname(txtPath), { recursive: true });
46
+ writeFileSync(
47
+ txtPath,
48
+ `Graph too large for HTML viewer (${totalNodeCount} nodes). Use 'xera:graph-query --format text' instead.\n`,
49
+ );
50
+ console.log(`[graph-render] graph too large (${totalNodeCount} nodes); wrote ${txtPath}`);
51
+ return 0;
52
+ }
53
+
54
+ const opts: RenderOpts = { depth, performanceMode };
55
+ if (ticketId) opts.ticketId = ticketId;
56
+ if (since) opts.since = since;
57
+
58
+ const data = transformForVisNetwork(snap, opts);
59
+ const html = renderHtml({ data, stats: data.stats, generatedAt: new Date().toISOString() });
60
+
61
+ mkdirSync(dirname(finalPath), { recursive: true });
62
+ const tmpPath = `${finalPath}.tmp`;
63
+ writeFileSync(tmpPath, html);
64
+ renameSync(tmpPath, finalPath);
65
+
66
+ console.log(
67
+ `[graph-render] wrote ${finalPath} (${data.stats.tickets} tickets · ${data.stats.scenarios} scenarios · ${html.length} bytes)`,
68
+ );
69
+ return 0;
70
+ }
@@ -0,0 +1,64 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import type { ImpactOpts, ImpactReport } from '../graph/impact';
4
+ import { renderImpactMarkdown, walkImpact } from '../graph/impact';
5
+ import { deriveSnapshot, loadAllEvents } from '../graph/store';
6
+ import type { Priority } from '../graph/types';
7
+
8
+ function parseDepth(s: string | undefined): 1 | 2 | 3 {
9
+ const n = s ? Number.parseInt(s, 10) : 2;
10
+ if (n === 1 || n === 3) return n;
11
+ return 2;
12
+ }
13
+
14
+ function parseMinPriority(s: string | undefined): Priority | undefined {
15
+ if (s === 'p0' || s === 'p1' || s === 'p2') return s;
16
+ return undefined;
17
+ }
18
+
19
+ export async function impactPrepareCmd(argv: string[]): Promise<number> {
20
+ const ticket = argv[0];
21
+ if (!ticket || ticket.startsWith('--')) {
22
+ console.error(
23
+ '[impact-prepare] usage: impact-prepare <TICKET> [--depth 1|2|3] [--min-priority p0|p1|p2] [--quiet]',
24
+ );
25
+ return 1;
26
+ }
27
+
28
+ let depth: 1 | 2 | 3 = 2;
29
+ let minPriority: Priority | undefined;
30
+ let quiet = false;
31
+ for (let i = 1; i < argv.length; i++) {
32
+ if (argv[i] === '--depth') depth = parseDepth(argv[++i]);
33
+ else if (argv[i] === '--min-priority') minPriority = parseMinPriority(argv[++i]);
34
+ else if (argv[i] === '--quiet') quiet = true;
35
+ }
36
+
37
+ const repoRoot = process.cwd();
38
+ const graph = deriveSnapshot(loadAllEvents(repoRoot));
39
+ const target = graph.tickets[ticket];
40
+ if (!target) {
41
+ console.error(`[impact-prepare] ticket ${ticket} not in graph; run /xera-fetch first`);
42
+ return 2;
43
+ }
44
+
45
+ const opts: ImpactOpts = { depth };
46
+ if (minPriority) opts.minPriority = minPriority;
47
+
48
+ const scenarios = walkImpact(graph, target, opts);
49
+
50
+ const report: ImpactReport = {
51
+ targetTicket: ticket,
52
+ modifiedAreas: target.modifiesAreas,
53
+ scenarios,
54
+ generatedAt: new Date().toISOString(),
55
+ };
56
+
57
+ const impactDir = join(repoRoot, '.xera/impact');
58
+ mkdirSync(impactDir, { recursive: true });
59
+ writeFileSync(join(impactDir, `${ticket}.json`), JSON.stringify(report, null, 2));
60
+ if (!quiet) {
61
+ writeFileSync(join(impactDir, `${ticket}.md`), renderImpactMarkdown(report));
62
+ }
63
+ return 0;
64
+ }
@@ -8,8 +8,10 @@ import { graphBackfillCmd } from './graph-backfill';
8
8
  import { graphEnrichCmd } from './graph-enrich';
9
9
  import { graphQueryCmd } from './graph-query';
10
10
  import { graphRecordCmd } from './graph-record';
11
+ import { graphRenderCmd } from './graph-render';
11
12
  import { graphSnapshotCmd } from './graph-snapshot';
12
13
  import { healPrepareCmd } from './heal-prepare';
14
+ import { impactPrepareCmd } from './impact-prepare';
13
15
  import { lintCmd } from './lint';
14
16
  import { normalizeCmd } from './normalize';
15
17
  import { postCmd } from './post';
@@ -30,10 +32,12 @@ const COMMANDS: Record<string, (argv: string[]) => Promise<number>> = {
30
32
  fetch: fetchCmd,
31
33
  'graph-backfill': graphBackfillCmd,
32
34
  'graph-enrich': graphEnrichCmd,
35
+ 'graph-render': graphRenderCmd,
33
36
  'graph-query': graphQueryCmd,
34
37
  'graph-record': graphRecordCmd,
35
38
  'graph-snapshot': graphSnapshotCmd,
36
39
  'heal-prepare': healPrepareCmd,
40
+ 'impact-prepare': impactPrepareCmd,
37
41
  lint: lintCmd,
38
42
  normalize: normalizeCmd,
39
43
  post: postCmd,
@@ -69,11 +69,23 @@ const ReportingSchema = z
69
69
  })
70
70
  .prefault({});
71
71
 
72
+ const RunSchema = z
73
+ .object({
74
+ autoImpact: z
75
+ .object({
76
+ enabled: z.boolean().default(true),
77
+ threshold: z.number().nonnegative().default(6.0),
78
+ })
79
+ .prefault({}),
80
+ })
81
+ .prefault({});
82
+
72
83
  export const XeraConfigSchema = z.object({
73
84
  jira: JiraSchema,
74
85
  web: WebSchema,
75
86
  ai: AISchema,
76
87
  reporting: ReportingSchema,
88
+ run: RunSchema.prefault({}),
77
89
  adapters: z.array(z.string().min(1)).min(1).default(['web']),
78
90
  });
79
91
 
@@ -0,0 +1,262 @@
1
+ import type { EdgeKind, Priority, Snapshot, TicketNode } from './types';
2
+
3
+ export interface ImpactEdge {
4
+ kind: EdgeKind;
5
+ from: string;
6
+ to: string;
7
+ confidence?: number;
8
+ source?: string;
9
+ }
10
+
11
+ export interface ImpactScenario {
12
+ scenarioId: string;
13
+ ticketId: string; // owner of the scenario (NOT the impact target)
14
+ name: string;
15
+ priority: Priority;
16
+ edgePath: ImpactEdge[];
17
+ riskScore: number;
18
+ lastPassedAt?: string;
19
+ }
20
+
21
+ export interface ImpactOpts {
22
+ depth: 1 | 2 | 3;
23
+ minPriority?: Priority;
24
+ }
25
+
26
+ export interface ImpactReport {
27
+ targetTicket: string;
28
+ modifiedAreas: string[];
29
+ scenarios: ImpactScenario[];
30
+ generatedAt: string;
31
+ }
32
+
33
+ const PRIORITY_WEIGHT: Record<Priority, number> = { p0: 3, p1: 2, p2: 1 };
34
+
35
+ const EDGE_WEIGHT_FIXED: Partial<Record<EdgeKind, number>> = {
36
+ modifies: 5, // direct collision via SUT area
37
+ uses: 4, // shared POM
38
+ covers: 4, // shared POM (alt path)
39
+ // 'jira-linked' weight is dynamic — see jiraRelationWeight
40
+ };
41
+
42
+ function jiraRelationWeight(source?: string): number {
43
+ if (!source) return 0;
44
+ if (source.endsWith('blocks')) return 4;
45
+ if (source.endsWith('duplicates')) return 3;
46
+ if (source.endsWith('relates')) return 2;
47
+ if (source.endsWith('supersedes')) return 3;
48
+ return 1;
49
+ }
50
+
51
+ function edgeWeight(edge: ImpactEdge): number {
52
+ if (edge.kind === 'modifies') return EDGE_WEIGHT_FIXED.modifies ?? 0;
53
+ if (edge.kind === 'uses' || edge.kind === 'covers') return EDGE_WEIGHT_FIXED.uses ?? 0;
54
+ if (edge.kind === 'jira-linked') return jiraRelationWeight(edge.source);
55
+ if (edge.kind === 'similar') return 1 * (edge.confidence ?? 0);
56
+ return 0;
57
+ }
58
+
59
+ export function riskScore(scenario: ImpactScenario, daysSinceLastPass: number): number {
60
+ const pri = PRIORITY_WEIGHT[scenario.priority] * 3;
61
+ const firstEdge = scenario.edgePath[0];
62
+ const edgeW = firstEdge ? edgeWeight(firstEdge) : 0;
63
+ const confW = firstEdge?.confidence !== undefined ? firstEdge.confidence * 2 : 0;
64
+ const decay = daysSinceLastPass * 0.1;
65
+ return pri + edgeW + confW - decay;
66
+ }
67
+
68
+ const PRIORITY_RANK: Record<Priority, number> = { p0: 3, p1: 2, p2: 1 };
69
+
70
+ function daysSince(ts: string | undefined): number {
71
+ if (!ts) return 0;
72
+ const ms = Date.now() - Date.parse(ts);
73
+ return ms < 0 ? 0 : ms / (86400 * 1000);
74
+ }
75
+
76
+ export function walkImpact(
77
+ graph: Snapshot,
78
+ target: TicketNode,
79
+ opts: ImpactOpts,
80
+ ): ImpactScenario[] {
81
+ const result: ImpactScenario[] = [];
82
+ const seen = new Set<string>();
83
+
84
+ // Areas the target modifies
85
+ const targetAreas = new Set(target.modifiesAreas);
86
+
87
+ // POMs covering any of those areas
88
+ const pomIds = graph.edges
89
+ .filter((e) => e.kind === 'covers' && targetAreas.has(e.to))
90
+ .map((e) => e.from);
91
+
92
+ // Scenarios using any of those POMs (depth 1 — direct collision)
93
+ const directScenarios = graph.edges
94
+ .filter((e) => e.kind === 'uses' && pomIds.includes(e.to))
95
+ .map((e) => e.from);
96
+
97
+ for (const scenarioId of directScenarios) {
98
+ if (seen.has(scenarioId)) continue;
99
+ const scenario = graph.scenarios[scenarioId];
100
+ if (!scenario) continue;
101
+ if (scenario.ticketId === target.id) continue; // exclude own scenarios
102
+
103
+ const usingPom = graph.edges.find((e) => e.kind === 'uses' && e.from === scenarioId);
104
+ const modifyEdge = graph.edges.find(
105
+ (e) => e.kind === 'modifies' && e.from === target.id && targetAreas.has(e.to),
106
+ );
107
+ const edgePath: ImpactEdge[] = [];
108
+ if (modifyEdge) edgePath.push({ kind: 'modifies', from: modifyEdge.from, to: modifyEdge.to });
109
+ if (usingPom) edgePath.push({ kind: 'uses', from: usingPom.from, to: usingPom.to });
110
+
111
+ seen.add(scenarioId);
112
+ const impact: ImpactScenario = {
113
+ scenarioId,
114
+ ticketId: scenario.ticketId,
115
+ name: scenario.name,
116
+ priority: scenario.priority,
117
+ edgePath,
118
+ riskScore: 0,
119
+ };
120
+ impact.riskScore = riskScore(impact, daysSince(graph.latest_failures[scenarioId]?.ts));
121
+ result.push(impact);
122
+ }
123
+
124
+ // Depth >= 2: jira-linked tickets contribute their scenarios
125
+ if (opts.depth >= 2) {
126
+ const linked = graph.edges
127
+ .filter((e) => e.kind === 'jira-linked' && e.from === target.id)
128
+ .map((e) => ({ to: e.to, source: e.source }));
129
+ for (const link of linked) {
130
+ const sceneIds = graph.edges
131
+ .filter((e) => e.kind === 'tests' && e.from === link.to)
132
+ .map((e) => e.to);
133
+ for (const scenarioId of sceneIds) {
134
+ if (seen.has(scenarioId)) continue;
135
+ const scenario = graph.scenarios[scenarioId];
136
+ if (!scenario || scenario.ticketId === target.id) continue;
137
+ seen.add(scenarioId);
138
+ const edge: ImpactEdge = { kind: 'jira-linked', from: target.id, to: link.to };
139
+ if (link.source !== undefined) edge.source = link.source;
140
+ const impact: ImpactScenario = {
141
+ scenarioId,
142
+ ticketId: scenario.ticketId,
143
+ name: scenario.name,
144
+ priority: scenario.priority,
145
+ edgePath: [edge],
146
+ riskScore: 0,
147
+ };
148
+ impact.riskScore = riskScore(impact, daysSince(graph.latest_failures[scenarioId]?.ts));
149
+ result.push(impact);
150
+ }
151
+ }
152
+ }
153
+
154
+ // Depth >= 3: similar tickets contribute their scenarios
155
+ if (opts.depth >= 3) {
156
+ const similar = graph.edges
157
+ .filter((e) => e.kind === 'similar' && e.from === target.id)
158
+ .map((e) => ({ to: e.to, confidence: e.confidence }));
159
+ for (const link of similar) {
160
+ const sceneIds = graph.edges
161
+ .filter((e) => e.kind === 'tests' && e.from === link.to)
162
+ .map((e) => e.to);
163
+ for (const scenarioId of sceneIds) {
164
+ if (seen.has(scenarioId)) continue;
165
+ const scenario = graph.scenarios[scenarioId];
166
+ if (!scenario || scenario.ticketId === target.id) continue;
167
+ seen.add(scenarioId);
168
+ const edge: ImpactEdge = { kind: 'similar', from: target.id, to: link.to };
169
+ if (link.confidence !== undefined) edge.confidence = link.confidence;
170
+ const impact: ImpactScenario = {
171
+ scenarioId,
172
+ ticketId: scenario.ticketId,
173
+ name: scenario.name,
174
+ priority: scenario.priority,
175
+ edgePath: [edge],
176
+ riskScore: 0,
177
+ };
178
+ impact.riskScore = riskScore(impact, daysSince(graph.latest_failures[scenarioId]?.ts));
179
+ result.push(impact);
180
+ }
181
+ }
182
+ }
183
+
184
+ // Filter by min-priority
185
+ let filtered = result;
186
+ if (opts.minPriority) {
187
+ const min = PRIORITY_RANK[opts.minPriority];
188
+ filtered = filtered.filter((s) => PRIORITY_RANK[s.priority] >= min);
189
+ }
190
+
191
+ // Sort by riskScore descending
192
+ filtered.sort((a, b) => b.riskScore - a.riskScore);
193
+ return filtered;
194
+ }
195
+
196
+ const HIGH_THRESHOLD = 7.0;
197
+ const MEDIUM_THRESHOLD = 4.0;
198
+
199
+ function bucket(score: number): 'high' | 'medium' | 'low' {
200
+ if (score >= HIGH_THRESHOLD) return 'high';
201
+ if (score >= MEDIUM_THRESHOLD) return 'medium';
202
+ return 'low';
203
+ }
204
+
205
+ function fmtEdgePath(path: ImpactEdge[]): string {
206
+ return path.map((e) => `${e.from} →[${e.kind}]→ ${e.to}`).join(' · ');
207
+ }
208
+
209
+ export function renderImpactMarkdown(report: ImpactReport): string {
210
+ const lines: string[] = [];
211
+ lines.push(`# Impact Analysis — ${report.targetTicket}`);
212
+ lines.push('');
213
+ lines.push(`**Modified areas:** ${report.modifiedAreas.join(', ') || '(none)'}`);
214
+ lines.push(`**Generated:** ${report.generatedAt}`);
215
+ lines.push('');
216
+
217
+ if (report.scenarios.length === 0) {
218
+ lines.push('No prior scenarios in the modified areas. This may be a new feature area.');
219
+ lines.push('');
220
+ return lines.join('\n');
221
+ }
222
+
223
+ const bySeverity = {
224
+ high: [] as ImpactScenario[],
225
+ medium: [] as ImpactScenario[],
226
+ low: [] as ImpactScenario[],
227
+ };
228
+ for (const s of report.scenarios) bySeverity[bucket(s.riskScore)].push(s);
229
+
230
+ lines.push(
231
+ `**Total impacted:** ${report.scenarios.length} scenarios (${bySeverity.high.length} high · ${bySeverity.medium.length} medium · ${bySeverity.low.length} low)`,
232
+ );
233
+ lines.push('');
234
+
235
+ for (const [name, scenarios] of [
236
+ ['High-risk', bySeverity.high],
237
+ ['Medium-risk', bySeverity.medium],
238
+ ['Low-risk', bySeverity.low],
239
+ ] as const) {
240
+ if (scenarios.length === 0) continue;
241
+ lines.push(`## ${name}`);
242
+ lines.push('');
243
+ for (const s of scenarios) {
244
+ lines.push(
245
+ `### ${s.ticketId} / "${s.name}" [${s.priority.toUpperCase()}] score ${s.riskScore.toFixed(1)}`,
246
+ );
247
+ lines.push(`- Edge: ${fmtEdgePath(s.edgePath)}`);
248
+ if (s.lastPassedAt) lines.push(`- Last passed: ${s.lastPassedAt}`);
249
+ lines.push('');
250
+ }
251
+ }
252
+
253
+ lines.push('## Re-run commands');
254
+ lines.push(`- All: \`bun run xera:exec --from-impact ${report.targetTicket}\``);
255
+ lines.push(
256
+ `- P0 only: \`bun run xera:exec --from-impact ${report.targetTicket} --min-priority p0\``,
257
+ );
258
+ lines.push(`- Select: \`bun run xera:exec --from-impact ${report.targetTicket} --select\``);
259
+ lines.push('');
260
+
261
+ return lines.join('\n');
262
+ }
@@ -14,7 +14,20 @@ export type { CostSummary, LlmCallLog } from './cost';
14
14
  export { logLlmCall, summarizeCost } from './cost';
15
15
  export type { EnrichOptions, EnrichResult } from './enrich';
16
16
  export { enrichTicket } from './enrich';
17
+ export type {
18
+ ImpactEdge,
19
+ ImpactOpts,
20
+ ImpactReport,
21
+ ImpactScenario,
22
+ } from './impact';
23
+ export {
24
+ renderImpactMarkdown,
25
+ riskScore,
26
+ walkImpact,
27
+ } from './impact';
17
28
  export { currentYyyyMm, graphPaths } from './paths';
29
+ export type { GraphStats, RenderHtmlInput, RenderOpts, VisEdge, VisNode } from './render';
30
+ export { renderHtml, transformForVisNetwork } from './render';
18
31
  export { EventSchema, safeParseEvent } from './schema';
19
32
  export { buildSimilarityPrompt } from './similarity';
20
33
  export {