@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.
Files changed (46) hide show
  1. package/dist/artifact/status.d.ts +4 -0
  2. package/dist/artifact/status.d.ts.map +1 -1
  3. package/dist/bin/internal.js +593 -72
  4. package/dist/bin-internal/graph-enrich.d.ts +2 -0
  5. package/dist/bin-internal/graph-enrich.d.ts.map +1 -0
  6. package/dist/bin-internal/graph-record.d.ts.map +1 -1
  7. package/dist/bin-internal/impact-prepare.d.ts +2 -0
  8. package/dist/bin-internal/impact-prepare.d.ts.map +1 -0
  9. package/dist/bin-internal/index.d.ts.map +1 -1
  10. package/dist/bin-internal/report.d.ts.map +1 -1
  11. package/dist/bin-internal/verify-prompts.d.ts.map +1 -1
  12. package/dist/classifier/aggregate.d.ts.map +1 -1
  13. package/dist/config/schema.d.ts +6 -0
  14. package/dist/config/schema.d.ts.map +1 -1
  15. package/dist/graph/classify.d.ts +42 -0
  16. package/dist/graph/classify.d.ts.map +1 -0
  17. package/dist/graph/enrich.d.ts +10 -0
  18. package/dist/graph/enrich.d.ts.map +1 -0
  19. package/dist/graph/impact.d.ts +31 -0
  20. package/dist/graph/impact.d.ts.map +1 -0
  21. package/dist/graph/index.d.ts +7 -0
  22. package/dist/graph/index.d.ts.map +1 -1
  23. package/dist/graph/schema.d.ts +3 -0
  24. package/dist/graph/schema.d.ts.map +1 -1
  25. package/dist/graph/similarity.d.ts +3 -0
  26. package/dist/graph/similarity.d.ts.map +1 -0
  27. package/dist/graph/types.d.ts +1 -1
  28. package/dist/graph/types.d.ts.map +1 -1
  29. package/dist/src/index.js +15 -1
  30. package/package.json +1 -1
  31. package/src/artifact/status.ts +8 -1
  32. package/src/bin-internal/graph-enrich.ts +28 -0
  33. package/src/bin-internal/graph-record.ts +45 -1
  34. package/src/bin-internal/impact-prepare.ts +64 -0
  35. package/src/bin-internal/index.ts +4 -0
  36. package/src/bin-internal/report.ts +63 -5
  37. package/src/bin-internal/verify-prompts.ts +2 -0
  38. package/src/classifier/aggregate.ts +1 -0
  39. package/src/config/schema.ts +12 -0
  40. package/src/graph/classify.ts +126 -0
  41. package/src/graph/enrich.ts +103 -0
  42. package/src/graph/impact.ts +262 -0
  43. package/src/graph/index.ts +26 -0
  44. package/src/graph/schema.ts +8 -1
  45. package/src/graph/similarity.ts +43 -0
  46. 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
+ }
@@ -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,
@@ -71,7 +71,14 @@ const runCompleted = z
71
71
  })
72
72
  .passthrough();
73
73
 
74
- const classification = z.enum(['REAL_BUG', 'TEST_BUG', 'SELECTOR_DRIFT', 'FLAKY', 'PASS']);
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
+ }
@@ -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 = 'REAL_BUG' | 'TEST_BUG' | 'SELECTOR_DRIFT' | 'FLAKY' | 'PASS';
10
- // Note: TEST_OUTDATED is added in v0.6.1.
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;