@xera-ai/core 0.4.1 → 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/bin/internal.js +281 -12
- 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/config/schema.d.ts +6 -0
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/graph/impact.d.ts +31 -0
- package/dist/graph/impact.d.ts.map +1 -0
- package/dist/graph/index.d.ts +2 -0
- package/dist/graph/index.d.ts.map +1 -1
- package/dist/src/index.js +7 -0
- package/package.json +1 -1
- package/src/bin-internal/impact-prepare.ts +64 -0
- package/src/bin-internal/index.ts +2 -0
- package/src/config/schema.ts +12 -0
- package/src/graph/impact.ts +262 -0
- package/src/graph/index.ts +11 -0
package/dist/bin/internal.js
CHANGED
|
@@ -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
|
|
|
@@ -10050,6 +10057,267 @@ async function healPrepareCmd(argv) {
|
|
|
10050
10057
|
}
|
|
10051
10058
|
}
|
|
10052
10059
|
|
|
10060
|
+
// src/bin-internal/impact-prepare.ts
|
|
10061
|
+
import { mkdirSync as mkdirSync11, writeFileSync as writeFileSync11 } from "fs";
|
|
10062
|
+
import { join as join17 } from "path";
|
|
10063
|
+
|
|
10064
|
+
// src/graph/impact.ts
|
|
10065
|
+
var PRIORITY_WEIGHT = { p0: 3, p1: 2, p2: 1 };
|
|
10066
|
+
var EDGE_WEIGHT_FIXED = {
|
|
10067
|
+
modifies: 5,
|
|
10068
|
+
uses: 4,
|
|
10069
|
+
covers: 4
|
|
10070
|
+
};
|
|
10071
|
+
function jiraRelationWeight(source) {
|
|
10072
|
+
if (!source)
|
|
10073
|
+
return 0;
|
|
10074
|
+
if (source.endsWith("blocks"))
|
|
10075
|
+
return 4;
|
|
10076
|
+
if (source.endsWith("duplicates"))
|
|
10077
|
+
return 3;
|
|
10078
|
+
if (source.endsWith("relates"))
|
|
10079
|
+
return 2;
|
|
10080
|
+
if (source.endsWith("supersedes"))
|
|
10081
|
+
return 3;
|
|
10082
|
+
return 1;
|
|
10083
|
+
}
|
|
10084
|
+
function edgeWeight(edge) {
|
|
10085
|
+
if (edge.kind === "modifies")
|
|
10086
|
+
return EDGE_WEIGHT_FIXED.modifies ?? 0;
|
|
10087
|
+
if (edge.kind === "uses" || edge.kind === "covers")
|
|
10088
|
+
return EDGE_WEIGHT_FIXED.uses ?? 0;
|
|
10089
|
+
if (edge.kind === "jira-linked")
|
|
10090
|
+
return jiraRelationWeight(edge.source);
|
|
10091
|
+
if (edge.kind === "similar")
|
|
10092
|
+
return 1 * (edge.confidence ?? 0);
|
|
10093
|
+
return 0;
|
|
10094
|
+
}
|
|
10095
|
+
function riskScore(scenario, daysSinceLastPass) {
|
|
10096
|
+
const pri = PRIORITY_WEIGHT[scenario.priority] * 3;
|
|
10097
|
+
const firstEdge = scenario.edgePath[0];
|
|
10098
|
+
const edgeW = firstEdge ? edgeWeight(firstEdge) : 0;
|
|
10099
|
+
const confW = firstEdge?.confidence !== undefined ? firstEdge.confidence * 2 : 0;
|
|
10100
|
+
const decay = daysSinceLastPass * 0.1;
|
|
10101
|
+
return pri + edgeW + confW - decay;
|
|
10102
|
+
}
|
|
10103
|
+
var PRIORITY_RANK = { p0: 3, p1: 2, p2: 1 };
|
|
10104
|
+
function daysSince(ts) {
|
|
10105
|
+
if (!ts)
|
|
10106
|
+
return 0;
|
|
10107
|
+
const ms = Date.now() - Date.parse(ts);
|
|
10108
|
+
return ms < 0 ? 0 : ms / (86400 * 1000);
|
|
10109
|
+
}
|
|
10110
|
+
function walkImpact(graph, target, opts) {
|
|
10111
|
+
const result = [];
|
|
10112
|
+
const seen = new Set;
|
|
10113
|
+
const targetAreas = new Set(target.modifiesAreas);
|
|
10114
|
+
const pomIds = graph.edges.filter((e) => e.kind === "covers" && targetAreas.has(e.to)).map((e) => e.from);
|
|
10115
|
+
const directScenarios = graph.edges.filter((e) => e.kind === "uses" && pomIds.includes(e.to)).map((e) => e.from);
|
|
10116
|
+
for (const scenarioId2 of directScenarios) {
|
|
10117
|
+
if (seen.has(scenarioId2))
|
|
10118
|
+
continue;
|
|
10119
|
+
const scenario = graph.scenarios[scenarioId2];
|
|
10120
|
+
if (!scenario)
|
|
10121
|
+
continue;
|
|
10122
|
+
if (scenario.ticketId === target.id)
|
|
10123
|
+
continue;
|
|
10124
|
+
const usingPom = graph.edges.find((e) => e.kind === "uses" && e.from === scenarioId2);
|
|
10125
|
+
const modifyEdge = graph.edges.find((e) => e.kind === "modifies" && e.from === target.id && targetAreas.has(e.to));
|
|
10126
|
+
const edgePath = [];
|
|
10127
|
+
if (modifyEdge)
|
|
10128
|
+
edgePath.push({ kind: "modifies", from: modifyEdge.from, to: modifyEdge.to });
|
|
10129
|
+
if (usingPom)
|
|
10130
|
+
edgePath.push({ kind: "uses", from: usingPom.from, to: usingPom.to });
|
|
10131
|
+
seen.add(scenarioId2);
|
|
10132
|
+
const impact = {
|
|
10133
|
+
scenarioId: scenarioId2,
|
|
10134
|
+
ticketId: scenario.ticketId,
|
|
10135
|
+
name: scenario.name,
|
|
10136
|
+
priority: scenario.priority,
|
|
10137
|
+
edgePath,
|
|
10138
|
+
riskScore: 0
|
|
10139
|
+
};
|
|
10140
|
+
impact.riskScore = riskScore(impact, daysSince(graph.latest_failures[scenarioId2]?.ts));
|
|
10141
|
+
result.push(impact);
|
|
10142
|
+
}
|
|
10143
|
+
if (opts.depth >= 2) {
|
|
10144
|
+
const linked = graph.edges.filter((e) => e.kind === "jira-linked" && e.from === target.id).map((e) => ({ to: e.to, source: e.source }));
|
|
10145
|
+
for (const link of linked) {
|
|
10146
|
+
const sceneIds = graph.edges.filter((e) => e.kind === "tests" && e.from === link.to).map((e) => e.to);
|
|
10147
|
+
for (const scenarioId2 of sceneIds) {
|
|
10148
|
+
if (seen.has(scenarioId2))
|
|
10149
|
+
continue;
|
|
10150
|
+
const scenario = graph.scenarios[scenarioId2];
|
|
10151
|
+
if (!scenario || scenario.ticketId === target.id)
|
|
10152
|
+
continue;
|
|
10153
|
+
seen.add(scenarioId2);
|
|
10154
|
+
const edge = { kind: "jira-linked", from: target.id, to: link.to };
|
|
10155
|
+
if (link.source !== undefined)
|
|
10156
|
+
edge.source = link.source;
|
|
10157
|
+
const impact = {
|
|
10158
|
+
scenarioId: scenarioId2,
|
|
10159
|
+
ticketId: scenario.ticketId,
|
|
10160
|
+
name: scenario.name,
|
|
10161
|
+
priority: scenario.priority,
|
|
10162
|
+
edgePath: [edge],
|
|
10163
|
+
riskScore: 0
|
|
10164
|
+
};
|
|
10165
|
+
impact.riskScore = riskScore(impact, daysSince(graph.latest_failures[scenarioId2]?.ts));
|
|
10166
|
+
result.push(impact);
|
|
10167
|
+
}
|
|
10168
|
+
}
|
|
10169
|
+
}
|
|
10170
|
+
if (opts.depth >= 3) {
|
|
10171
|
+
const similar = graph.edges.filter((e) => e.kind === "similar" && e.from === target.id).map((e) => ({ to: e.to, confidence: e.confidence }));
|
|
10172
|
+
for (const link of similar) {
|
|
10173
|
+
const sceneIds = graph.edges.filter((e) => e.kind === "tests" && e.from === link.to).map((e) => e.to);
|
|
10174
|
+
for (const scenarioId2 of sceneIds) {
|
|
10175
|
+
if (seen.has(scenarioId2))
|
|
10176
|
+
continue;
|
|
10177
|
+
const scenario = graph.scenarios[scenarioId2];
|
|
10178
|
+
if (!scenario || scenario.ticketId === target.id)
|
|
10179
|
+
continue;
|
|
10180
|
+
seen.add(scenarioId2);
|
|
10181
|
+
const edge = { kind: "similar", from: target.id, to: link.to };
|
|
10182
|
+
if (link.confidence !== undefined)
|
|
10183
|
+
edge.confidence = link.confidence;
|
|
10184
|
+
const impact = {
|
|
10185
|
+
scenarioId: scenarioId2,
|
|
10186
|
+
ticketId: scenario.ticketId,
|
|
10187
|
+
name: scenario.name,
|
|
10188
|
+
priority: scenario.priority,
|
|
10189
|
+
edgePath: [edge],
|
|
10190
|
+
riskScore: 0
|
|
10191
|
+
};
|
|
10192
|
+
impact.riskScore = riskScore(impact, daysSince(graph.latest_failures[scenarioId2]?.ts));
|
|
10193
|
+
result.push(impact);
|
|
10194
|
+
}
|
|
10195
|
+
}
|
|
10196
|
+
}
|
|
10197
|
+
let filtered = result;
|
|
10198
|
+
if (opts.minPriority) {
|
|
10199
|
+
const min = PRIORITY_RANK[opts.minPriority];
|
|
10200
|
+
filtered = filtered.filter((s) => PRIORITY_RANK[s.priority] >= min);
|
|
10201
|
+
}
|
|
10202
|
+
filtered.sort((a, b) => b.riskScore - a.riskScore);
|
|
10203
|
+
return filtered;
|
|
10204
|
+
}
|
|
10205
|
+
var HIGH_THRESHOLD = 7;
|
|
10206
|
+
var MEDIUM_THRESHOLD = 4;
|
|
10207
|
+
function bucket(score) {
|
|
10208
|
+
if (score >= HIGH_THRESHOLD)
|
|
10209
|
+
return "high";
|
|
10210
|
+
if (score >= MEDIUM_THRESHOLD)
|
|
10211
|
+
return "medium";
|
|
10212
|
+
return "low";
|
|
10213
|
+
}
|
|
10214
|
+
function fmtEdgePath(path) {
|
|
10215
|
+
return path.map((e) => `${e.from} \u2192[${e.kind}]\u2192 ${e.to}`).join(" \xB7 ");
|
|
10216
|
+
}
|
|
10217
|
+
function renderImpactMarkdown(report) {
|
|
10218
|
+
const lines = [];
|
|
10219
|
+
lines.push(`# Impact Analysis \u2014 ${report.targetTicket}`);
|
|
10220
|
+
lines.push("");
|
|
10221
|
+
lines.push(`**Modified areas:** ${report.modifiedAreas.join(", ") || "(none)"}`);
|
|
10222
|
+
lines.push(`**Generated:** ${report.generatedAt}`);
|
|
10223
|
+
lines.push("");
|
|
10224
|
+
if (report.scenarios.length === 0) {
|
|
10225
|
+
lines.push("No prior scenarios in the modified areas. This may be a new feature area.");
|
|
10226
|
+
lines.push("");
|
|
10227
|
+
return lines.join(`
|
|
10228
|
+
`);
|
|
10229
|
+
}
|
|
10230
|
+
const bySeverity = {
|
|
10231
|
+
high: [],
|
|
10232
|
+
medium: [],
|
|
10233
|
+
low: []
|
|
10234
|
+
};
|
|
10235
|
+
for (const s of report.scenarios)
|
|
10236
|
+
bySeverity[bucket(s.riskScore)].push(s);
|
|
10237
|
+
lines.push(`**Total impacted:** ${report.scenarios.length} scenarios (${bySeverity.high.length} high \xB7 ${bySeverity.medium.length} medium \xB7 ${bySeverity.low.length} low)`);
|
|
10238
|
+
lines.push("");
|
|
10239
|
+
for (const [name, scenarios] of [
|
|
10240
|
+
["High-risk", bySeverity.high],
|
|
10241
|
+
["Medium-risk", bySeverity.medium],
|
|
10242
|
+
["Low-risk", bySeverity.low]
|
|
10243
|
+
]) {
|
|
10244
|
+
if (scenarios.length === 0)
|
|
10245
|
+
continue;
|
|
10246
|
+
lines.push(`## ${name}`);
|
|
10247
|
+
lines.push("");
|
|
10248
|
+
for (const s of scenarios) {
|
|
10249
|
+
lines.push(`### ${s.ticketId} / "${s.name}" [${s.priority.toUpperCase()}] score ${s.riskScore.toFixed(1)}`);
|
|
10250
|
+
lines.push(`- Edge: ${fmtEdgePath(s.edgePath)}`);
|
|
10251
|
+
if (s.lastPassedAt)
|
|
10252
|
+
lines.push(`- Last passed: ${s.lastPassedAt}`);
|
|
10253
|
+
lines.push("");
|
|
10254
|
+
}
|
|
10255
|
+
}
|
|
10256
|
+
lines.push("## Re-run commands");
|
|
10257
|
+
lines.push(`- All: \`bun run xera:exec --from-impact ${report.targetTicket}\``);
|
|
10258
|
+
lines.push(`- P0 only: \`bun run xera:exec --from-impact ${report.targetTicket} --min-priority p0\``);
|
|
10259
|
+
lines.push(`- Select: \`bun run xera:exec --from-impact ${report.targetTicket} --select\``);
|
|
10260
|
+
lines.push("");
|
|
10261
|
+
return lines.join(`
|
|
10262
|
+
`);
|
|
10263
|
+
}
|
|
10264
|
+
|
|
10265
|
+
// src/bin-internal/impact-prepare.ts
|
|
10266
|
+
init_store();
|
|
10267
|
+
function parseDepth(s) {
|
|
10268
|
+
const n = s ? Number.parseInt(s, 10) : 2;
|
|
10269
|
+
if (n === 1 || n === 3)
|
|
10270
|
+
return n;
|
|
10271
|
+
return 2;
|
|
10272
|
+
}
|
|
10273
|
+
function parseMinPriority(s) {
|
|
10274
|
+
if (s === "p0" || s === "p1" || s === "p2")
|
|
10275
|
+
return s;
|
|
10276
|
+
return;
|
|
10277
|
+
}
|
|
10278
|
+
async function impactPrepareCmd(argv) {
|
|
10279
|
+
const ticket = argv[0];
|
|
10280
|
+
if (!ticket || ticket.startsWith("--")) {
|
|
10281
|
+
console.error("[impact-prepare] usage: impact-prepare <TICKET> [--depth 1|2|3] [--min-priority p0|p1|p2] [--quiet]");
|
|
10282
|
+
return 1;
|
|
10283
|
+
}
|
|
10284
|
+
let depth = 2;
|
|
10285
|
+
let minPriority;
|
|
10286
|
+
let quiet = false;
|
|
10287
|
+
for (let i2 = 1;i2 < argv.length; i2++) {
|
|
10288
|
+
if (argv[i2] === "--depth")
|
|
10289
|
+
depth = parseDepth(argv[++i2]);
|
|
10290
|
+
else if (argv[i2] === "--min-priority")
|
|
10291
|
+
minPriority = parseMinPriority(argv[++i2]);
|
|
10292
|
+
else if (argv[i2] === "--quiet")
|
|
10293
|
+
quiet = true;
|
|
10294
|
+
}
|
|
10295
|
+
const repoRoot = process.cwd();
|
|
10296
|
+
const graph = deriveSnapshot(loadAllEvents(repoRoot));
|
|
10297
|
+
const target = graph.tickets[ticket];
|
|
10298
|
+
if (!target) {
|
|
10299
|
+
console.error(`[impact-prepare] ticket ${ticket} not in graph; run /xera-fetch first`);
|
|
10300
|
+
return 2;
|
|
10301
|
+
}
|
|
10302
|
+
const opts = { depth };
|
|
10303
|
+
if (minPriority)
|
|
10304
|
+
opts.minPriority = minPriority;
|
|
10305
|
+
const scenarios = walkImpact(graph, target, opts);
|
|
10306
|
+
const report = {
|
|
10307
|
+
targetTicket: ticket,
|
|
10308
|
+
modifiedAreas: target.modifiesAreas,
|
|
10309
|
+
scenarios,
|
|
10310
|
+
generatedAt: new Date().toISOString()
|
|
10311
|
+
};
|
|
10312
|
+
const impactDir = join17(repoRoot, ".xera/impact");
|
|
10313
|
+
mkdirSync11(impactDir, { recursive: true });
|
|
10314
|
+
writeFileSync11(join17(impactDir, `${ticket}.json`), JSON.stringify(report, null, 2));
|
|
10315
|
+
if (!quiet) {
|
|
10316
|
+
writeFileSync11(join17(impactDir, `${ticket}.md`), renderImpactMarkdown(report));
|
|
10317
|
+
}
|
|
10318
|
+
return 0;
|
|
10319
|
+
}
|
|
10320
|
+
|
|
10053
10321
|
// src/bin-internal/lint.ts
|
|
10054
10322
|
import { lintTicket } from "@xera-ai/web";
|
|
10055
10323
|
async function lintCmd(argv) {
|
|
@@ -10071,7 +10339,7 @@ async function lintCmd(argv) {
|
|
|
10071
10339
|
|
|
10072
10340
|
// src/bin-internal/normalize.ts
|
|
10073
10341
|
import { existsSync as existsSync21, readdirSync as readdirSync7 } from "fs";
|
|
10074
|
-
import { join as
|
|
10342
|
+
import { join as join18 } from "path";
|
|
10075
10343
|
import { normalizeRun } from "@xera-ai/web";
|
|
10076
10344
|
async function normalizeCmd(argv) {
|
|
10077
10345
|
const ticket = argv[0];
|
|
@@ -10086,7 +10354,7 @@ async function normalizeCmd(argv) {
|
|
|
10086
10354
|
console.error("[xera:normalize] no run found");
|
|
10087
10355
|
return 1;
|
|
10088
10356
|
}
|
|
10089
|
-
const runDir =
|
|
10357
|
+
const runDir = join18(paths.runsDir, runId);
|
|
10090
10358
|
if (!existsSync21(runDir)) {
|
|
10091
10359
|
console.error(`[xera:normalize] runs/${runId} missing`);
|
|
10092
10360
|
return 1;
|
|
@@ -10098,10 +10366,10 @@ async function normalizeCmd(argv) {
|
|
|
10098
10366
|
|
|
10099
10367
|
// src/bin-internal/post.ts
|
|
10100
10368
|
import { existsSync as existsSync23, readFileSync as readFileSync19 } from "fs";
|
|
10101
|
-
import { join as
|
|
10369
|
+
import { join as join19 } from "path";
|
|
10102
10370
|
|
|
10103
10371
|
// src/artifact/status.ts
|
|
10104
|
-
import { existsSync as existsSync22, mkdirSync as
|
|
10372
|
+
import { existsSync as existsSync22, mkdirSync as mkdirSync12, readFileSync as readFileSync18, writeFileSync as writeFileSync12 } from "fs";
|
|
10105
10373
|
import { dirname as dirname6 } from "path";
|
|
10106
10374
|
import { z as z7 } from "zod";
|
|
10107
10375
|
var ClassificationEnum = z7.enum([
|
|
@@ -10141,8 +10409,8 @@ function readStatus(path) {
|
|
|
10141
10409
|
return StatusJsonSchema.parse(JSON.parse(readFileSync18(path, "utf8")));
|
|
10142
10410
|
}
|
|
10143
10411
|
function writeStatus(path, status) {
|
|
10144
|
-
|
|
10145
|
-
|
|
10412
|
+
mkdirSync12(dirname6(path), { recursive: true });
|
|
10413
|
+
writeFileSync12(path, JSON.stringify(status, null, 2));
|
|
10146
10414
|
}
|
|
10147
10415
|
function appendHistory(path, entry) {
|
|
10148
10416
|
const s = readStatus(path);
|
|
@@ -10168,7 +10436,7 @@ async function postCmd(argv) {
|
|
|
10168
10436
|
return 0;
|
|
10169
10437
|
}
|
|
10170
10438
|
const paths = resolveArtifactPaths(cwd, ticket);
|
|
10171
|
-
const draftPath =
|
|
10439
|
+
const draftPath = join19(paths.ticketDir, "jira-comment.draft.md");
|
|
10172
10440
|
if (!existsSync23(draftPath)) {
|
|
10173
10441
|
console.error(`[xera:post] no draft at ${draftPath}; run \`xera-internal report\` first.`);
|
|
10174
10442
|
return 1;
|
|
@@ -10201,8 +10469,8 @@ async function promoteCmd(argv) {
|
|
|
10201
10469
|
}
|
|
10202
10470
|
|
|
10203
10471
|
// src/bin-internal/report.ts
|
|
10204
|
-
import { existsSync as existsSync25, readFileSync as readFileSync20, writeFileSync as
|
|
10205
|
-
import { join as
|
|
10472
|
+
import { existsSync as existsSync25, readFileSync as readFileSync20, writeFileSync as writeFileSync13 } from "fs";
|
|
10473
|
+
import { join as join20 } from "path";
|
|
10206
10474
|
|
|
10207
10475
|
// src/classifier/aggregate.ts
|
|
10208
10476
|
var CLASS_PRIORITY = [
|
|
@@ -10376,7 +10644,7 @@ async function reportCmd(argv) {
|
|
|
10376
10644
|
const paths = resolveArtifactPaths(process.cwd(), ticket);
|
|
10377
10645
|
const input = JSON.parse(readFileSync20(inputArg.slice("--input=".length), "utf8"));
|
|
10378
10646
|
const aggregated = aggregateScenarios(input.scenarios);
|
|
10379
|
-
const decisionsPath =
|
|
10647
|
+
const decisionsPath = join20(paths.ticketDir, "runs", input.runId, "outdated-decisions.json");
|
|
10380
10648
|
const decisions = existsSync25(decisionsPath) ? JSON.parse(readFileSync20(decisionsPath, "utf8")) : {};
|
|
10381
10649
|
const graph = deriveSnapshot(loadAllEvents(process.cwd()));
|
|
10382
10650
|
const normalizeScenarioName = (name) => name.trim().toLowerCase().replace(/\s+/g, " ");
|
|
@@ -10425,8 +10693,8 @@ async function reportCmd(argv) {
|
|
|
10425
10693
|
xeraVersion: "0.1.0",
|
|
10426
10694
|
promptsVersion: "1.0.0"
|
|
10427
10695
|
});
|
|
10428
|
-
const draftPath =
|
|
10429
|
-
|
|
10696
|
+
const draftPath = join20(paths.ticketDir, "jira-comment.draft.md");
|
|
10697
|
+
writeFileSync13(draftPath, md);
|
|
10430
10698
|
console.log(`[xera:report] wrote status.json and ${draftPath}`);
|
|
10431
10699
|
return 0;
|
|
10432
10700
|
}
|
|
@@ -10530,6 +10798,7 @@ var COMMANDS = {
|
|
|
10530
10798
|
"graph-record": graphRecordCmd,
|
|
10531
10799
|
"graph-snapshot": graphSnapshotCmd,
|
|
10532
10800
|
"heal-prepare": healPrepareCmd,
|
|
10801
|
+
"impact-prepare": impactPrepareCmd,
|
|
10533
10802
|
lint: lintCmd,
|
|
10534
10803
|
normalize: normalizeCmd,
|
|
10535
10804
|
post: postCmd,
|
|
@@ -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":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/bin-internal/index.ts"],"names":[],"mappings":"AAkDA,wBAAsB,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAczD"}
|
package/dist/config/schema.d.ts
CHANGED
|
@@ -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;
|
|
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"}
|
package/dist/graph/index.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ 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';
|
|
8
10
|
export { EventSchema, safeParseEvent } from './schema';
|
|
9
11
|
export { buildSimilarityPrompt } from './similarity';
|
|
@@ -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,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"}
|
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
|
@@ -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
|
+
}
|
|
@@ -10,6 +10,7 @@ import { graphQueryCmd } from './graph-query';
|
|
|
10
10
|
import { graphRecordCmd } from './graph-record';
|
|
11
11
|
import { graphSnapshotCmd } from './graph-snapshot';
|
|
12
12
|
import { healPrepareCmd } from './heal-prepare';
|
|
13
|
+
import { impactPrepareCmd } from './impact-prepare';
|
|
13
14
|
import { lintCmd } from './lint';
|
|
14
15
|
import { normalizeCmd } from './normalize';
|
|
15
16
|
import { postCmd } from './post';
|
|
@@ -34,6 +35,7 @@ const COMMANDS: Record<string, (argv: string[]) => Promise<number>> = {
|
|
|
34
35
|
'graph-record': graphRecordCmd,
|
|
35
36
|
'graph-snapshot': graphSnapshotCmd,
|
|
36
37
|
'heal-prepare': healPrepareCmd,
|
|
38
|
+
'impact-prepare': impactPrepareCmd,
|
|
37
39
|
lint: lintCmd,
|
|
38
40
|
normalize: normalizeCmd,
|
|
39
41
|
post: postCmd,
|
package/src/config/schema.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/graph/index.ts
CHANGED
|
@@ -14,6 +14,17 @@ 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';
|
|
18
29
|
export { EventSchema, safeParseEvent } from './schema';
|
|
19
30
|
export { buildSimilarityPrompt } from './similarity';
|