@xera-ai/core 0.4.0 → 0.4.2
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/artifact/status.d.ts +4 -0
- package/dist/artifact/status.d.ts.map +1 -1
- package/dist/bin/internal.js +593 -72
- package/dist/bin-internal/graph-enrich.d.ts +2 -0
- package/dist/bin-internal/graph-enrich.d.ts.map +1 -0
- package/dist/bin-internal/graph-record.d.ts.map +1 -1
- package/dist/bin-internal/impact-prepare.d.ts +2 -0
- package/dist/bin-internal/impact-prepare.d.ts.map +1 -0
- package/dist/bin-internal/index.d.ts.map +1 -1
- package/dist/bin-internal/report.d.ts.map +1 -1
- package/dist/bin-internal/verify-prompts.d.ts.map +1 -1
- package/dist/classifier/aggregate.d.ts.map +1 -1
- package/dist/config/schema.d.ts +6 -0
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/graph/classify.d.ts +42 -0
- package/dist/graph/classify.d.ts.map +1 -0
- package/dist/graph/enrich.d.ts +10 -0
- package/dist/graph/enrich.d.ts.map +1 -0
- package/dist/graph/impact.d.ts +31 -0
- package/dist/graph/impact.d.ts.map +1 -0
- package/dist/graph/index.d.ts +7 -0
- package/dist/graph/index.d.ts.map +1 -1
- package/dist/graph/schema.d.ts +3 -0
- package/dist/graph/schema.d.ts.map +1 -1
- package/dist/graph/similarity.d.ts +3 -0
- package/dist/graph/similarity.d.ts.map +1 -0
- package/dist/graph/types.d.ts +1 -1
- package/dist/graph/types.d.ts.map +1 -1
- package/dist/src/index.js +15 -1
- package/package.json +1 -1
- package/src/artifact/status.ts +8 -1
- package/src/bin-internal/graph-enrich.ts +28 -0
- package/src/bin-internal/graph-record.ts +45 -1
- package/src/bin-internal/impact-prepare.ts +64 -0
- package/src/bin-internal/index.ts +4 -0
- package/src/bin-internal/report.ts +63 -5
- package/src/bin-internal/verify-prompts.ts +2 -0
- package/src/classifier/aggregate.ts +1 -0
- package/src/config/schema.ts +12 -0
- package/src/graph/classify.ts +126 -0
- package/src/graph/enrich.ts +103 -0
- package/src/graph/impact.ts +262 -0
- package/src/graph/index.ts +26 -0
- package/src/graph/schema.ts +8 -1
- package/src/graph/similarity.ts +43 -0
- package/src/graph/types.ts +7 -2
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { Classification, ScenarioNode, Snapshot, TicketNode } from './types';
|
|
2
|
+
|
|
3
|
+
export interface ClassifyInput {
|
|
4
|
+
scenarioId: string;
|
|
5
|
+
traceClassification: Classification;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface CandidateEvidence {
|
|
9
|
+
ticketId: string;
|
|
10
|
+
summary: string;
|
|
11
|
+
modifiedArea: string;
|
|
12
|
+
relevantAcRef?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ClassifyEvidence {
|
|
16
|
+
candidateTickets?: CandidateEvidence[];
|
|
17
|
+
reasoning?: string;
|
|
18
|
+
expectedByTest?: string;
|
|
19
|
+
actualInApp?: string;
|
|
20
|
+
proposedAction?: 'regenerate-scenario' | 'review-and-decide';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ClassifyOutput {
|
|
24
|
+
classification: Classification;
|
|
25
|
+
confidence: number;
|
|
26
|
+
evidence?: ClassifyEvidence;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface OutdatedDecision {
|
|
30
|
+
classification: 'TEST_OUTDATED' | 'BUG' | 'AMBIGUOUS';
|
|
31
|
+
confidence: number;
|
|
32
|
+
evidence: {
|
|
33
|
+
reasoning: string;
|
|
34
|
+
expectedByTest?: string;
|
|
35
|
+
actualInApp?: string;
|
|
36
|
+
relevantAcRef?: string;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type DecideOutdated = (args: {
|
|
41
|
+
scenario: ScenarioNode;
|
|
42
|
+
candidates: TicketNode[];
|
|
43
|
+
}) => Promise<OutdatedDecision>;
|
|
44
|
+
|
|
45
|
+
const DEFAULT_THRESHOLD = 0.7;
|
|
46
|
+
const SHORT_CIRCUIT: Classification[] = ['FLAKY', 'PASS'];
|
|
47
|
+
|
|
48
|
+
export function findCandidateTickets(graph: Snapshot, scenario: ScenarioNode): TicketNode[] {
|
|
49
|
+
const poms = graph.edges
|
|
50
|
+
.filter((e) => e.kind === 'uses' && e.from === scenario.id)
|
|
51
|
+
.map((e) => e.to);
|
|
52
|
+
if (poms.length === 0) return [];
|
|
53
|
+
|
|
54
|
+
const areas = graph.edges
|
|
55
|
+
.filter((e) => e.kind === 'covers' && poms.includes(e.from))
|
|
56
|
+
.map((e) => e.to);
|
|
57
|
+
if (areas.length === 0) return [];
|
|
58
|
+
|
|
59
|
+
const ticketIds = graph.edges
|
|
60
|
+
.filter((e) => e.kind === 'modifies' && areas.includes(e.to))
|
|
61
|
+
.map((e) => e.from);
|
|
62
|
+
|
|
63
|
+
const seen = new Set<string>();
|
|
64
|
+
const out: TicketNode[] = [];
|
|
65
|
+
for (const id of ticketIds) {
|
|
66
|
+
if (seen.has(id)) continue;
|
|
67
|
+
seen.add(id);
|
|
68
|
+
if (id === scenario.ticketId) continue;
|
|
69
|
+
const t = graph.tickets[id];
|
|
70
|
+
if (!t) continue;
|
|
71
|
+
if (t.fetchedAt <= scenario.generatedAt) continue;
|
|
72
|
+
out.push(t);
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function enhanceClassification(
|
|
78
|
+
input: ClassifyInput,
|
|
79
|
+
graph: Snapshot,
|
|
80
|
+
decideOutdated: DecideOutdated,
|
|
81
|
+
options: { threshold?: number } = {},
|
|
82
|
+
): Promise<ClassifyOutput> {
|
|
83
|
+
const threshold = options.threshold ?? DEFAULT_THRESHOLD;
|
|
84
|
+
if (SHORT_CIRCUIT.includes(input.traceClassification)) {
|
|
85
|
+
return { classification: input.traceClassification, confidence: 1 };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const scenario = graph.scenarios[input.scenarioId];
|
|
89
|
+
if (!scenario) return { classification: input.traceClassification, confidence: 1 };
|
|
90
|
+
|
|
91
|
+
const candidates = findCandidateTickets(graph, scenario);
|
|
92
|
+
if (candidates.length === 0) {
|
|
93
|
+
return { classification: input.traceClassification, confidence: 1 };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const candidateEvidence: CandidateEvidence[] = candidates.map((t) => {
|
|
97
|
+
const area = graph.edges.find((e) => e.kind === 'modifies' && e.from === t.id)?.to ?? '';
|
|
98
|
+
const ev: CandidateEvidence = { ticketId: t.id, summary: t.summary, modifiedArea: area };
|
|
99
|
+
if (t.ac[0]) ev.relevantAcRef = t.ac[0];
|
|
100
|
+
return ev;
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const decision = await decideOutdated({ scenario, candidates });
|
|
104
|
+
|
|
105
|
+
if (decision.classification === 'TEST_OUTDATED' && decision.confidence >= threshold) {
|
|
106
|
+
const evidence: ClassifyEvidence = {
|
|
107
|
+
candidateTickets: candidateEvidence,
|
|
108
|
+
reasoning: decision.evidence.reasoning,
|
|
109
|
+
proposedAction: 'regenerate-scenario',
|
|
110
|
+
};
|
|
111
|
+
if (decision.evidence.expectedByTest)
|
|
112
|
+
evidence.expectedByTest = decision.evidence.expectedByTest;
|
|
113
|
+
if (decision.evidence.actualInApp) evidence.actualInApp = decision.evidence.actualInApp;
|
|
114
|
+
return {
|
|
115
|
+
classification: 'TEST_OUTDATED',
|
|
116
|
+
confidence: decision.confidence,
|
|
117
|
+
evidence,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
classification: input.traceClassification,
|
|
123
|
+
confidence: 1,
|
|
124
|
+
evidence: { candidateTickets: candidateEvidence },
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { appendEvents, deriveSnapshot, loadAllEvents } from './store';
|
|
5
|
+
import type { EdgeDiscoveredPayload, Event, TicketEnrichedPayload } from './types';
|
|
6
|
+
import { SCHEMA_VERSION } from './types';
|
|
7
|
+
import { ulid } from './ulid';
|
|
8
|
+
|
|
9
|
+
const MAX_SIMILAR_EDGES = 10;
|
|
10
|
+
const MIN_CONFIDENCE = 0.7;
|
|
11
|
+
|
|
12
|
+
const SimilarEntrySchema = z.object({
|
|
13
|
+
ticketId: z.string().regex(/^[A-Z][A-Z0-9]*-\d+$/),
|
|
14
|
+
confidence: z.number(),
|
|
15
|
+
reason: z.string(),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const EnrichmentInputSchema = z.object({
|
|
19
|
+
similar: z.array(SimilarEntrySchema),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export interface EnrichOptions {
|
|
23
|
+
force?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface EnrichResult {
|
|
27
|
+
ticketId: string;
|
|
28
|
+
similarCount: number;
|
|
29
|
+
enrichedAt: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const nowIso = () => new Date().toISOString();
|
|
33
|
+
|
|
34
|
+
const mk = <T extends Event['type']>(
|
|
35
|
+
actor: string,
|
|
36
|
+
type: T,
|
|
37
|
+
payload: Extract<Event, { type: T }>['payload'],
|
|
38
|
+
): Event =>
|
|
39
|
+
({
|
|
40
|
+
event_id: ulid(),
|
|
41
|
+
schema_version: SCHEMA_VERSION,
|
|
42
|
+
ts: nowIso(),
|
|
43
|
+
actor,
|
|
44
|
+
type,
|
|
45
|
+
payload,
|
|
46
|
+
}) as Event;
|
|
47
|
+
|
|
48
|
+
export async function enrichTicket(
|
|
49
|
+
repoRoot: string,
|
|
50
|
+
ticketId: string,
|
|
51
|
+
opts: EnrichOptions,
|
|
52
|
+
): Promise<EnrichResult> {
|
|
53
|
+
const inputPath = join(repoRoot, '.xera', ticketId, 'enrichment-input.json');
|
|
54
|
+
if (!existsSync(inputPath)) {
|
|
55
|
+
throw new Error(`enrichment-input.json not found at ${inputPath}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const raw = JSON.parse(readFileSync(inputPath, 'utf8'));
|
|
59
|
+
const parsed = EnrichmentInputSchema.safeParse(raw);
|
|
60
|
+
if (!parsed.success) {
|
|
61
|
+
throw new Error(`invalid enrichment-input.json: ${parsed.error.message}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const snapshot = deriveSnapshot(loadAllEvents(repoRoot));
|
|
65
|
+
if (!snapshot.tickets[ticketId]) {
|
|
66
|
+
throw new Error(`ticket ${ticketId} not in graph; run /xera-fetch first`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (snapshot.tickets[ticketId]!.enrichedAt && !opts.force) {
|
|
70
|
+
return { ticketId, similarCount: 0, enrichedAt: snapshot.tickets[ticketId]!.enrichedAt! };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const validated = parsed.data.similar
|
|
74
|
+
.map((s) => ({ ...s, confidence: Math.max(0, Math.min(1, s.confidence)) }))
|
|
75
|
+
.filter((s) => s.confidence >= MIN_CONFIDENCE)
|
|
76
|
+
.filter((s) => snapshot.tickets[s.ticketId] !== undefined)
|
|
77
|
+
.filter((s) => s.ticketId !== ticketId)
|
|
78
|
+
.slice(0, MAX_SIMILAR_EDGES);
|
|
79
|
+
|
|
80
|
+
const events: Event[] = [];
|
|
81
|
+
for (const s of validated) {
|
|
82
|
+
const payload: EdgeDiscoveredPayload = {
|
|
83
|
+
kind: 'similar',
|
|
84
|
+
from: ticketId,
|
|
85
|
+
to: s.ticketId,
|
|
86
|
+
confidence: s.confidence,
|
|
87
|
+
source: `llm-similarity:${s.reason.slice(0, 80)}`,
|
|
88
|
+
};
|
|
89
|
+
events.push(mk('graph-enrich', 'edge.discovered', payload));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const enrichedAt = nowIso();
|
|
93
|
+
const enrichedPayload: TicketEnrichedPayload = {
|
|
94
|
+
ticketId,
|
|
95
|
+
enrichedAt,
|
|
96
|
+
similarCount: validated.length,
|
|
97
|
+
};
|
|
98
|
+
events.push(mk('graph-enrich', 'ticket.enriched', enrichedPayload));
|
|
99
|
+
|
|
100
|
+
appendEvents(repoRoot, events, { skill: 'graph-enrich', ticketId });
|
|
101
|
+
|
|
102
|
+
return { ticketId, similarCount: validated.length, enrichedAt };
|
|
103
|
+
}
|
|
@@ -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
|
+
}
|
package/src/graph/index.ts
CHANGED
|
@@ -1,7 +1,33 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
CandidateEvidence,
|
|
3
|
+
ClassifyEvidence,
|
|
4
|
+
ClassifyInput,
|
|
5
|
+
ClassifyOutput,
|
|
6
|
+
DecideOutdated,
|
|
7
|
+
OutdatedDecision,
|
|
8
|
+
} from './classify';
|
|
9
|
+
export {
|
|
10
|
+
enhanceClassification,
|
|
11
|
+
findCandidateTickets,
|
|
12
|
+
} from './classify';
|
|
1
13
|
export type { CostSummary, LlmCallLog } from './cost';
|
|
2
14
|
export { logLlmCall, summarizeCost } from './cost';
|
|
15
|
+
export type { EnrichOptions, EnrichResult } from './enrich';
|
|
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';
|
|
3
28
|
export { currentYyyyMm, graphPaths } from './paths';
|
|
4
29
|
export { EventSchema, safeParseEvent } from './schema';
|
|
30
|
+
export { buildSimilarityPrompt } from './similarity';
|
|
5
31
|
export {
|
|
6
32
|
appendEvents,
|
|
7
33
|
computeEventsHash,
|
package/src/graph/schema.ts
CHANGED
|
@@ -71,7 +71,14 @@ const runCompleted = z
|
|
|
71
71
|
})
|
|
72
72
|
.passthrough();
|
|
73
73
|
|
|
74
|
-
const classification = z.enum([
|
|
74
|
+
const classification = z.enum([
|
|
75
|
+
'REAL_BUG',
|
|
76
|
+
'TEST_BUG',
|
|
77
|
+
'SELECTOR_DRIFT',
|
|
78
|
+
'FLAKY',
|
|
79
|
+
'PASS',
|
|
80
|
+
'TEST_OUTDATED',
|
|
81
|
+
]);
|
|
75
82
|
|
|
76
83
|
const runClassified = z
|
|
77
84
|
.object({
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { TicketNode } from './types';
|
|
2
|
+
|
|
3
|
+
const MAX_CANDIDATES = 50;
|
|
4
|
+
|
|
5
|
+
export function buildSimilarityPrompt(target: TicketNode, candidates: TicketNode[]): string {
|
|
6
|
+
const window = candidates.slice(0, MAX_CANDIDATES);
|
|
7
|
+
const candidateBlock = window
|
|
8
|
+
.map((t, i) => {
|
|
9
|
+
const ac = t.ac.length > 0 ? `\n AC: ${t.ac.slice(0, 3).join(' | ')}` : '';
|
|
10
|
+
return `${i + 1}. ${t.id} — ${t.summary}${ac}`;
|
|
11
|
+
})
|
|
12
|
+
.join('\n');
|
|
13
|
+
|
|
14
|
+
const targetAc =
|
|
15
|
+
target.ac.length > 0 ? `\nAC:\n${target.ac.map((a) => ` - ${a}`).join('\n')}` : '';
|
|
16
|
+
|
|
17
|
+
return `You are evaluating whether a NEW ticket is semantically related to any prior tickets in this project's knowledge graph.
|
|
18
|
+
|
|
19
|
+
# NEW TICKET
|
|
20
|
+
ID: ${target.id}
|
|
21
|
+
Summary: ${target.summary}${targetAc}
|
|
22
|
+
|
|
23
|
+
# PRIOR TICKETS (most recent ${window.length} of ${candidates.length})
|
|
24
|
+
${candidateBlock || '(none yet)'}
|
|
25
|
+
|
|
26
|
+
# Task
|
|
27
|
+
Output a JSON object with shape:
|
|
28
|
+
|
|
29
|
+
\`\`\`json
|
|
30
|
+
{
|
|
31
|
+
"similar": [
|
|
32
|
+
{ "ticketId": "<JIRA-KEY>", "confidence": 0.0-1.0, "reason": "<one sentence>" }
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
\`\`\`
|
|
36
|
+
|
|
37
|
+
# Rules
|
|
38
|
+
1. Only include candidates with confidence ≥ 0.7. Below that, exclude.
|
|
39
|
+
2. Confidence reflects semantic relatedness (same SUT area, same flow, complementary feature) — NOT just word overlap.
|
|
40
|
+
3. Cap output at 10 entries even if more candidates pass the threshold; pick the highest-confidence ones.
|
|
41
|
+
4. If NO candidates are related, return \`{ "similar": [] }\`. Do not invent relationships.
|
|
42
|
+
5. Output JSON ONLY. No prose, no fences, no commentary.`;
|
|
43
|
+
}
|
package/src/graph/types.ts
CHANGED
|
@@ -6,8 +6,13 @@ export type Priority = 'p0' | 'p1' | 'p2';
|
|
|
6
6
|
export type ScenarioStatus = 'pass' | 'fail';
|
|
7
7
|
export type EdgeKind = 'tests' | 'uses' | 'covers' | 'modifies' | 'jira-linked' | 'similar' | 'ran';
|
|
8
8
|
|
|
9
|
-
export type Classification =
|
|
10
|
-
|
|
9
|
+
export type Classification =
|
|
10
|
+
| 'REAL_BUG'
|
|
11
|
+
| 'TEST_BUG'
|
|
12
|
+
| 'SELECTOR_DRIFT'
|
|
13
|
+
| 'FLAKY'
|
|
14
|
+
| 'PASS'
|
|
15
|
+
| 'TEST_OUTDATED';
|
|
11
16
|
|
|
12
17
|
export interface TicketFetchedPayload {
|
|
13
18
|
ticketId: string;
|