@elench/testkit 0.1.150 → 0.1.151

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 (48) hide show
  1. package/README.md +21 -9
  2. package/lib/cli/assistant/view-model.mjs +1 -1
  3. package/lib/cli/components/blocks/run-tree.mjs +1 -1
  4. package/lib/cli/renderers/run/events.mjs +4 -3
  5. package/lib/cli/renderers/run/failure.mjs +2 -2
  6. package/lib/cli/renderers/run/inline-detail.mjs +2 -2
  7. package/lib/cli/renderers/run/interactive.mjs +2 -2
  8. package/lib/cli/renderers/run/text-reporter.mjs +9 -9
  9. package/lib/cli/state/run/model.mjs +7 -7
  10. package/lib/cli/state/run/state.mjs +3 -3
  11. package/lib/cli/terminal/colors.mjs +1 -1
  12. package/lib/config/runtime.mjs +130 -0
  13. package/lib/config-api/index.d.ts +22 -0
  14. package/lib/database/cleanup.mjs +76 -1
  15. package/lib/database/index.mjs +6 -0
  16. package/lib/database/local-postgres.mjs +95 -4
  17. package/lib/database/naming.mjs +7 -0
  18. package/lib/database/state-files.mjs +12 -0
  19. package/lib/docker-compat/matrix.mjs +5 -3
  20. package/lib/kiln/client.mjs +8 -0
  21. package/lib/local/kiln-driver.mjs +96 -69
  22. package/lib/ownership/docker.mjs +67 -1
  23. package/lib/regressions/github-transport.mjs +178 -4
  24. package/lib/regressions/github.mjs +52 -16
  25. package/lib/regressions/index.d.ts +56 -28
  26. package/lib/regressions/index.mjs +122 -47
  27. package/lib/regressions/workflow.mjs +266 -0
  28. package/lib/results/artifacts.mjs +8 -7
  29. package/lib/runner/formatting.mjs +17 -16
  30. package/lib/runner/orchestrator.mjs +5 -4
  31. package/lib/runner/regressions.mjs +175 -33
  32. package/lib/runner/run-finalization.mjs +34 -4
  33. package/node_modules/@elench/next-analysis/package.json +1 -1
  34. package/node_modules/@elench/testkit-bridge/package.json +2 -2
  35. package/node_modules/@elench/testkit-protocol/package.json +1 -1
  36. package/node_modules/@elench/ts-analysis/package.json +1 -1
  37. package/node_modules/es-toolkit/CHANGELOG.md +801 -0
  38. package/node_modules/es-toolkit/src/compat/_internal/Equals.d.ts +1 -0
  39. package/node_modules/es-toolkit/src/compat/_internal/IsWritable.d.ts +3 -0
  40. package/node_modules/es-toolkit/src/compat/_internal/MutableList.d.ts +4 -0
  41. package/node_modules/es-toolkit/src/compat/_internal/RejectReadonly.d.ts +4 -0
  42. package/node_modules/esprima/ChangeLog +235 -0
  43. package/package.json +6 -5
  44. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts +0 -188
  45. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts.map +0 -1
  46. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js +0 -293
  47. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js.map +0 -1
  48. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/package.json +0 -25
@@ -3,90 +3,121 @@ import path from "path";
3
3
 
4
4
  const CLASSIFICATIONS = new Set([
5
5
  "expected_failure",
6
+ "flaky",
7
+ "harness_bug",
6
8
  "infra",
7
9
  "product_bug",
8
10
  "stale_test",
9
11
  "test_bug",
10
12
  ]);
11
13
 
12
- export function loadRegressionCatalogConfig(productDir, config) {
14
+ const CASE_STATES = new Set([
15
+ "active",
16
+ "coverage_missing",
17
+ "fixed_pending_verification",
18
+ "fix_claimed",
19
+ "resolved",
20
+ "untriaged",
21
+ ]);
22
+
23
+ export function loadRegressionCaseStoreConfig(productDir, config) {
13
24
  const relativePath = config?.file;
14
25
  if (!relativePath) return null;
15
26
 
16
27
  const absolutePath = path.resolve(productDir, relativePath);
17
28
  if (!fs.existsSync(absolutePath)) {
18
- throw new Error(`Regression catalog not found: ${relativePath}`);
29
+ throw new Error(`Regression case store not found: ${relativePath}`);
19
30
  }
20
31
 
21
- return loadRegressionCatalogDocument(absolutePath, relativePath);
32
+ const document = loadRegressionCaseStoreDocument(absolutePath, relativePath);
33
+ Object.defineProperty(document, "__filePath", {
34
+ value: absolutePath,
35
+ enumerable: false,
36
+ configurable: false,
37
+ });
38
+ return document;
22
39
  }
23
40
 
24
- export function loadRegressionCatalogDocument(filePath, relativePath = filePath) {
41
+ export function writeRegressionCaseStoreDocument(filePath, document) {
42
+ fs.writeFileSync(filePath, `${JSON.stringify(serializeRegressionCaseStore(document), null, 2)}\n`);
43
+ }
44
+
45
+ export function serializeRegressionCaseStore(document) {
46
+ const normalized = normalizeRegressionCaseStoreDocument(document);
47
+ return {
48
+ schemaVersion: 2,
49
+ issueRepo: normalized.issueRepo,
50
+ cases: normalized.cases,
51
+ };
52
+ }
53
+
54
+ export function loadRegressionCaseStoreDocument(filePath, relativePath = filePath) {
25
55
  let parsed;
26
56
  try {
27
57
  parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
28
58
  } catch (error) {
29
59
  throw new Error(
30
- `Could not parse regression catalog ${relativePath}: ${formatErrorMessage(error)}`
60
+ `Could not parse regression case store ${relativePath}: ${formatErrorMessage(error)}`
31
61
  );
32
62
  }
33
63
 
34
- return normalizeRegressionCatalogDocument(parsed, relativePath);
64
+ return normalizeRegressionCaseStoreDocument(parsed, relativePath);
35
65
  }
36
66
 
37
- export function normalizeRegressionCatalogDocument(document, relativePath = "regression catalog") {
67
+ export function normalizeRegressionCaseStoreDocument(document, relativePath = "regression case store") {
38
68
  if (!document || typeof document !== "object") {
39
69
  throw new Error(`${relativePath} must contain a JSON object`);
40
70
  }
41
- if (document.schemaVersion !== 1) {
42
- throw new Error(`${relativePath} schemaVersion must be 1`);
71
+ if (document.schemaVersion !== 2) {
72
+ throw new Error(`${relativePath} schemaVersion must be 2`);
43
73
  }
44
- if (!Array.isArray(document.entries)) {
45
- throw new Error(`${relativePath} entries must be an array`);
74
+ if (!Array.isArray(document.cases)) {
75
+ throw new Error(`${relativePath} cases must be an array`);
46
76
  }
47
77
 
48
78
  const ids = new Set();
49
- const entries = document.entries.map((entry, index) => {
50
- const normalized = normalizeRegressionEntry(entry, `${relativePath} entries[${index}]`);
79
+ const cases = document.cases.map((entry, index) => {
80
+ const normalized = normalizeRegressionCase(entry, `${relativePath} cases[${index}]`);
51
81
  if (ids.has(normalized.id)) {
52
- throw new Error(`${relativePath} has duplicate entry id "${normalized.id}"`);
82
+ throw new Error(`${relativePath} has duplicate case id "${normalized.id}"`);
53
83
  }
54
84
  ids.add(normalized.id);
55
85
  return normalized;
56
86
  });
57
87
 
58
88
  return {
59
- schemaVersion: 1,
89
+ schemaVersion: 2,
60
90
  issueRepo: normalizeOptionalString(document.issueRepo),
61
- entries,
91
+ cases,
62
92
  };
63
93
  }
64
94
 
65
- export function validateRegressionCatalogDocument(
95
+ export function validateRegressionCaseStoreDocument(
66
96
  document,
67
97
  { productDir, statusArtifactPath, statusArtifact } = {}
68
98
  ) {
99
+ const normalizedDocument = normalizeRegressionCaseStoreDocument(document);
69
100
  const errors = [];
70
101
  const warnings = [];
71
102
  const fingerprintKeys = new Set();
72
103
  let fingerprintCount = 0;
73
104
 
74
- for (const entry of document.entries) {
75
- for (const fingerprint of entry.fingerprints) {
105
+ for (const regressionCase of normalizedDocument.cases) {
106
+ for (const fingerprint of regressionCase.fingerprints) {
76
107
  fingerprintCount += 1;
77
108
  if (productDir) {
78
109
  if (fingerprint.path) {
79
110
  const absolutePath = path.join(productDir, fingerprint.path);
80
111
  if (!fs.existsSync(absolutePath)) {
81
- errors.push(`Missing matched file: ${fingerprint.path} (${entry.id})`);
112
+ errors.push(`Missing matched file: ${fingerprint.path} (${regressionCase.id})`);
82
113
  }
83
114
  } else if (!fingerprint.pathGlob) {
84
- errors.push(`Missing matched file: ${fingerprint.path} (${entry.id})`);
115
+ errors.push(`Missing matched file: ${fingerprint.path} (${regressionCase.id})`);
85
116
  }
86
117
  }
87
118
 
88
119
  const fingerprintKey = [
89
- entry.id,
120
+ regressionCase.id,
90
121
  fingerprint.service || "",
91
122
  fingerprint.type || "",
92
123
  fingerprint.path || "",
@@ -95,28 +126,29 @@ export function validateRegressionCatalogDocument(
95
126
  fingerprint.errorIncludes || "",
96
127
  ].join("::");
97
128
  if (fingerprintKeys.has(fingerprintKey)) {
98
- errors.push(`Duplicate fingerprint selector in ${entry.id}: ${fingerprint.path}`);
129
+ errors.push(`Duplicate fingerprint selector in ${regressionCase.id}: ${fingerprint.path}`);
99
130
  }
100
131
  fingerprintKeys.add(fingerprintKey);
101
132
 
102
133
  const displayPath = fingerprint.path || fingerprint.pathGlob;
103
134
  if (!displayPath.includes("__testkit__") || !displayPath.endsWith(".testkit.ts")) {
104
135
  warnings.push(
105
- `Fingerprint path does not look like a testkit file: ${displayPath} (${entry.id})`
136
+ `Fingerprint path does not look like a testkit file: ${displayPath} (${regressionCase.id})`
106
137
  );
107
138
  }
108
139
  }
109
140
 
110
- if (document.issueRepo && entry.issue.repo !== document.issueRepo) {
141
+ if (
142
+ normalizedDocument.issueRepo &&
143
+ regressionCase.issue &&
144
+ regressionCase.issue.repo !== normalizedDocument.issueRepo
145
+ ) {
111
146
  warnings.push(
112
- `Entry ${entry.id} uses issue repo ${entry.issue.repo} instead of document issueRepo ${document.issueRepo}`
147
+ `Case ${regressionCase.id} uses issue repo ${regressionCase.issue.repo} instead of document issueRepo ${normalizedDocument.issueRepo}`
113
148
  );
114
149
  }
115
150
  }
116
151
 
117
- let failedTests = 0;
118
- let diagnosedFailedTests = 0;
119
- let newFailedTests = 0;
120
152
  const parsedStatusArtifact = statusArtifact
121
153
  ? parseStatusArtifact(statusArtifact)
122
154
  : statusArtifactPath && fs.existsSync(statusArtifactPath)
@@ -124,13 +156,14 @@ export function validateRegressionCatalogDocument(
124
156
  : { tests: [] };
125
157
 
126
158
  const failed = parsedStatusArtifact.tests.filter((test) => test.status === "failed");
127
- failedTests = failed.length;
159
+ let diagnosedFailedTests = 0;
160
+ let newFailedTests = 0;
128
161
  for (const test of failed) {
129
- if (findMatchingRegressionEntries(document, test).length > 0) {
162
+ if (findMatchingRegressionCases(normalizedDocument, test).length > 0) {
130
163
  diagnosedFailedTests += 1;
131
164
  } else {
132
165
  newFailedTests += 1;
133
- warnings.push(`New failing test not yet in regression catalog: ${test.path}`);
166
+ warnings.push(`New failing test not yet in regression case store: ${test.path}`);
134
167
  }
135
168
  }
136
169
 
@@ -138,30 +171,37 @@ export function validateRegressionCatalogDocument(
138
171
  errors,
139
172
  warnings,
140
173
  stats: {
141
- entries: document.entries.length,
174
+ cases: normalizedDocument.cases.length,
142
175
  fingerprints: fingerprintCount,
143
- failedTests,
176
+ failedTests: failed.length,
144
177
  diagnosedFailedTests,
145
178
  newFailedTests,
146
179
  },
147
180
  };
148
181
  }
149
182
 
150
- export function renderRegressionCatalogMarkdown(document) {
183
+ export function renderRegressionCaseStoreMarkdown(document) {
184
+ const normalizedDocument = normalizeRegressionCaseStoreDocument(document);
185
+ const issueRefs = new Set(
186
+ normalizedDocument.cases
187
+ .map((entry) => entry.issue ? `${entry.issue.repo}#${entry.issue.number}` : null)
188
+ .filter(Boolean)
189
+ );
151
190
  const lines = [
152
- "# Regression Catalog",
191
+ "# Regression Cases",
153
192
  "",
154
193
  "Canonical source: `testkit.regressions.json`",
155
194
  "",
156
- `Tracked regressions: ${document.entries.length}`,
157
- `Tracked issue references: ${new Set(document.entries.map((entry) => entry.issue.number)).size}`,
195
+ `Tracked cases: ${normalizedDocument.cases.length}`,
196
+ `Tracked issue references: ${issueRefs.size}`,
158
197
  "",
159
198
  ];
160
199
 
161
- document.entries.forEach((entry, index) => {
200
+ normalizedDocument.cases.forEach((entry, index) => {
162
201
  lines.push(
163
- `## ${index + 1}. ${entry.summary} #${entry.issue.number}`,
202
+ `## ${index + 1}. ${entry.summary}${entry.issue ? ` - #${entry.issue.number}` : ""}`,
164
203
  "",
204
+ `- State: \`${entry.state}\``,
165
205
  `- Classification: \`${entry.classification}\``,
166
206
  `- Cause: ${entry.cause}`,
167
207
  `- Last reviewed: ${entry.lastReviewedAt}`,
@@ -170,8 +210,8 @@ export function renderRegressionCatalogMarkdown(document) {
170
210
 
171
211
  for (const fingerprint of entry.fingerprints) {
172
212
  lines.push(
173
- ` - \`${fingerprint.path || fingerprint.pathGlob}\`${fingerprint.failureKey ? ` \`${fingerprint.failureKey}\`` : ""}${
174
- fingerprint.errorIncludes ? ` error contains \`${fingerprint.errorIncludes}\`` : ""
213
+ ` - \`${fingerprint.path || fingerprint.pathGlob}\`${fingerprint.failureKey ? ` - \`${fingerprint.failureKey}\`` : ""}${
214
+ fingerprint.errorIncludes ? ` - error contains \`${fingerprint.errorIncludes}\`` : ""
175
215
  }`
176
216
  );
177
217
  }
@@ -182,11 +222,13 @@ export function renderRegressionCatalogMarkdown(document) {
182
222
  return `${lines.join("\n").trimEnd()}\n`;
183
223
  }
184
224
 
185
- export function findMatchingRegressionEntries(document, fileSummary) {
186
- return document.entries.filter((entry) => matchesRegressionEntry(entry, fileSummary));
225
+ export function findMatchingRegressionCases(document, fileSummary) {
226
+ return (document?.cases || []).filter((entry) =>
227
+ matchesRegressionCase(entry, fileSummary)
228
+ );
187
229
  }
188
230
 
189
- export function matchesRegressionEntry(entry, fileSummary) {
231
+ export function matchesRegressionCase(entry, fileSummary) {
190
232
  return entry.fingerprints.some((fingerprint) =>
191
233
  matchesRegressionFingerprint(fingerprint, fileSummary)
192
234
  );
@@ -213,17 +255,24 @@ export function buildRegressionFileIdentity(service, type, filePath) {
213
255
  return `${service}::${type}::${filePath}`;
214
256
  }
215
257
 
216
- function normalizeRegressionEntry(entry, label) {
258
+ export function issueRefToString(issue) {
259
+ if (!issue) return null;
260
+ return `${issue.repo}#${issue.number}`;
261
+ }
262
+
263
+ function normalizeRegressionCase(entry, label) {
217
264
  if (!entry || typeof entry !== "object") {
218
265
  throw new Error(`${label} must be an object`);
219
266
  }
220
267
 
221
268
  const id = requireNonEmptyString(entry.id, `${label}.id`);
269
+ const state = requireEnumValue(entry.state || "active", CASE_STATES, `${label}.state`);
222
270
  const classification = requireEnumValue(
223
271
  entry.classification,
224
272
  CLASSIFICATIONS,
225
273
  `${label}.classification`
226
274
  );
275
+ const owner = normalizeOptionalString(entry.owner);
227
276
  const summary = requireNonEmptyString(entry.summary, `${label}.summary`);
228
277
  const cause = requireNonEmptyString(entry.cause, `${label}.cause`);
229
278
  const lastReviewedAt = requireNonEmptyString(entry.lastReviewedAt, `${label}.lastReviewedAt`);
@@ -233,14 +282,18 @@ function normalizeRegressionEntry(entry, label) {
233
282
 
234
283
  return {
235
284
  id,
285
+ state,
236
286
  classification,
237
- issue: normalizeRegressionIssue(entry.issue, `${label}.issue`),
287
+ ...(owner ? { owner } : {}),
288
+ issue: entry.issue == null ? null : normalizeRegressionIssue(entry.issue, `${label}.issue`),
238
289
  summary,
239
290
  cause,
240
291
  lastReviewedAt,
241
292
  fingerprints: entry.fingerprints.map((fingerprint, index) =>
242
293
  normalizeRegressionFingerprint(fingerprint, `${label}.fingerprints[${index}]`)
243
294
  ),
295
+ lifecycle: normalizeLifecycle(entry.lifecycle),
296
+ coverage: normalizeCoverage(entry.coverage),
244
297
  };
245
298
  }
246
299
 
@@ -258,6 +311,28 @@ function normalizeRegressionIssue(issue, label) {
258
311
  return { repo, number };
259
312
  }
260
313
 
314
+ function normalizeLifecycle(value) {
315
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
316
+ return {
317
+ ...(normalizeOptionalString(value.firstSeenAt) ? { firstSeenAt: normalizeOptionalString(value.firstSeenAt) } : {}),
318
+ ...(normalizeOptionalString(value.lastSeenAt) ? { lastSeenAt: normalizeOptionalString(value.lastSeenAt) } : {}),
319
+ ...(normalizeOptionalString(value.lastVerifiedAt) ? { lastVerifiedAt: normalizeOptionalString(value.lastVerifiedAt) } : {}),
320
+ ...(normalizeOptionalString(value.resolvedAt) ? { resolvedAt: normalizeOptionalString(value.resolvedAt) } : {}),
321
+ ...(value.cleanRunCount != null ? { cleanRunCount: Math.max(0, Number(value.cleanRunCount) || 0) } : {}),
322
+ ...(value.fixClaim && typeof value.fixClaim === "object" && !Array.isArray(value.fixClaim)
323
+ ? { fixClaim: { ...value.fixClaim } }
324
+ : {}),
325
+ };
326
+ }
327
+
328
+ function normalizeCoverage(value) {
329
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
330
+ return {
331
+ ...(typeof value.required === "boolean" ? { required: value.required } : {}),
332
+ ...(normalizeOptionalString(value.status) ? { status: normalizeOptionalString(value.status) } : {}),
333
+ };
334
+ }
335
+
261
336
  function normalizeRegressionFingerprint(fingerprint, label) {
262
337
  if (!fingerprint || typeof fingerprint !== "object") {
263
338
  throw new Error(`${label} must be an object`);
@@ -0,0 +1,266 @@
1
+ import { execFileSync } from "child_process";
2
+ import { createDefaultGitHubIssueTransport } from "./github-transport.mjs";
3
+
4
+ const STRONG_FIX_RE = /\b(?:fixe[sd]?|close[sd]?|resolve[sd]?)\s+((?:[\w.-]+\/[\w.-]+)?#\d+)/giu;
5
+ const WEAK_REF_RE = /\b(?:refs?|references?|related to)\s+((?:[\w.-]+\/[\w.-]+)?#\d+)/giu;
6
+
7
+ export function parseIssueReferences(text, defaultRepo = null) {
8
+ const refs = [];
9
+ collectRefs(refs, text, STRONG_FIX_RE, "fix");
10
+ collectRefs(refs, text, WEAK_REF_RE, "reference");
11
+ const seen = new Set();
12
+ return refs
13
+ .map((ref) => normalizeIssueRef(ref, defaultRepo))
14
+ .filter(Boolean)
15
+ .filter((ref) => {
16
+ const key = `${ref.kind}:${ref.repo}#${ref.number}`;
17
+ if (seen.has(key)) return false;
18
+ seen.add(key);
19
+ return true;
20
+ });
21
+ }
22
+
23
+ export function collectLocalFixClaims(productDir, gitMetadata = {}) {
24
+ const defaultRepo = gitMetadata?.repoSlug || null;
25
+ const messages = [];
26
+ try {
27
+ messages.push(execFileSync("git", ["log", "-20", "--pretty=%B"], {
28
+ cwd: productDir,
29
+ encoding: "utf8",
30
+ stdio: ["ignore", "pipe", "ignore"],
31
+ }));
32
+ } catch {
33
+ return [];
34
+ }
35
+ return parseIssueReferences(messages.join("\n"), defaultRepo).filter((ref) => ref.kind === "fix");
36
+ }
37
+
38
+ export async function executeRegressionWorkflowActions({
39
+ productDir,
40
+ caseStore,
41
+ report,
42
+ workflowConfig,
43
+ gitMetadata,
44
+ env = process.env,
45
+ }) {
46
+ if (!caseStore || !report || !workflowConfig || workflowConfig.mode === "off") {
47
+ return { changed: false, actions: [] };
48
+ }
49
+
50
+ const actions = [];
51
+ let changed = false;
52
+ const transport = await maybeCreateGitHubTransport(workflowConfig, env);
53
+
54
+ if (workflowConfig?.github?.read !== false && transport?.fetchOpenBugIssues && caseStore.issueRepo) {
55
+ try {
56
+ const openBugIssues = await transport.fetchOpenBugIssues(caseStore.issueRepo, {
57
+ labels: workflowConfig.github?.bugLabels || ["bug"],
58
+ });
59
+ const coverage = await reconcileBugIssueCoverage({
60
+ caseStore,
61
+ issuesByNumber: openBugIssues,
62
+ issueRepo: caseStore.issueRepo,
63
+ });
64
+ if (coverage.changed) changed = true;
65
+ actions.push(...coverage.actions);
66
+ } catch (error) {
67
+ actions.push({
68
+ type: "sync_bug_coverage",
69
+ reason: "github_bug_issue_fetch_failed",
70
+ status: "suggested",
71
+ message: error instanceof Error ? error.message : String(error),
72
+ });
73
+ }
74
+ }
75
+
76
+ const fixClaims = collectLocalFixClaims(productDir, gitMetadata);
77
+ const cases = caseStore.cases || [];
78
+ const issueToCase = new Map(
79
+ cases
80
+ .filter((entry) => entry.issue)
81
+ .map((entry) => [`${entry.issue.repo}#${entry.issue.number}`, entry])
82
+ );
83
+
84
+ for (const claim of fixClaims) {
85
+ const regressionCase = issueToCase.get(`${claim.repo}#${claim.number}`);
86
+ if (!regressionCase) continue;
87
+ const previousState = JSON.stringify({
88
+ state: regressionCase.state,
89
+ lifecycle: regressionCase.lifecycle || {},
90
+ });
91
+ regressionCase.lifecycle = {
92
+ ...(regressionCase.lifecycle || {}),
93
+ fixClaim: {
94
+ repo: claim.repo,
95
+ number: claim.number,
96
+ source: "git",
97
+ observedAt: new Date().toISOString(),
98
+ },
99
+ };
100
+ if (regressionCase.state === "active") regressionCase.state = "fix_claimed";
101
+ changed = changed || previousState !== JSON.stringify({
102
+ state: regressionCase.state,
103
+ lifecycle: regressionCase.lifecycle || {},
104
+ });
105
+ actions.push({
106
+ type: "record_fix_claim",
107
+ caseId: regressionCase.id,
108
+ issue: regressionCase.issue,
109
+ reason: "commit_message_fix_reference",
110
+ status: "applied",
111
+ });
112
+ }
113
+
114
+ if (workflowConfig?.github?.write && transport) {
115
+ const writeResult = await executeGitHubWriteActions({ transport, caseStore, report, actions, workflowConfig });
116
+ if (writeResult.changed) changed = true;
117
+ }
118
+
119
+ if (actions.length > 0) {
120
+ report.workflow = report.workflow || { actions: [], summary: { applied: 0, suggested: 0, byType: {} } };
121
+ report.workflow.actions = [...(report.workflow.actions || []), ...actions];
122
+ report.workflow.summary = summarizeWorkflowActions(report.workflow.actions);
123
+ }
124
+
125
+ return { changed: changed || actions.some((action) => action.status === "applied"), actions };
126
+ }
127
+
128
+ export async function reconcileBugIssueCoverage({ caseStore, issuesByNumber, issueRepo }) {
129
+ const actions = [];
130
+ if (!caseStore || !issuesByNumber || issuesByNumber.size === 0) return { changed: false, actions };
131
+ const cases = caseStore.cases || [];
132
+ const coveredIssueNumbers = new Set(cases.filter((entry) => entry.issue).map((entry) => entry.issue.number));
133
+
134
+ for (const [number, issue] of issuesByNumber.entries()) {
135
+ if (coveredIssueNumbers.has(number)) continue;
136
+ const id = `github-bug-${number}`;
137
+ if (cases.some((entry) => entry.id === id)) continue;
138
+ cases.push({
139
+ id,
140
+ state: "coverage_missing",
141
+ classification: "product_bug",
142
+ issue: { repo: issue.repo || issueRepo, number },
143
+ summary: issue.title || `Bug #${number} lacks regression coverage`,
144
+ cause: "Open GitHub bug issue has no linked Testkit regression case.",
145
+ lastReviewedAt: new Date().toISOString().slice(0, 10),
146
+ fingerprints: [{ pathGlob: "**/__testkit__/*.testkit.ts", service: "unknown", type: "int", errorIncludes: id }],
147
+ lifecycle: {},
148
+ coverage: { required: true, status: "coverage_missing" },
149
+ });
150
+ actions.push({
151
+ type: "record_missing_bug_coverage",
152
+ caseId: id,
153
+ issue: { repo: issue.repo || issueRepo, number },
154
+ reason: "open_bug_issue_without_case",
155
+ status: "applied",
156
+ });
157
+ }
158
+
159
+ return { changed: actions.length > 0, actions };
160
+ }
161
+
162
+ async function executeGitHubWriteActions({ transport, caseStore, report, actions, workflowConfig }) {
163
+ const cases = caseStore.cases || [];
164
+ const caseById = new Map(cases.map((entry) => [entry.id, entry]));
165
+ let changed = false;
166
+
167
+ for (const action of report.workflow?.actions || []) {
168
+ if (action.status !== "suggested") continue;
169
+ if (action.type === "reopen_issue" && workflowConfig.github.reopenIssues !== "never" && action.issue) {
170
+ await transport.updateIssueState(action.issue.repo, action.issue.number, "open");
171
+ changed = true;
172
+ actions.push({ ...action, status: "applied" });
173
+ continue;
174
+ }
175
+ }
176
+
177
+ if (workflowConfig.github.closeIssues === "after_stability_window") {
178
+ const requiredCleanRuns = workflowConfig.stability?.cleanRunsBeforeClose || 3;
179
+ for (const regressionCase of cases) {
180
+ if (regressionCase.state !== "fixed_pending_verification" || !regressionCase.issue) continue;
181
+ if (Number(regressionCase.lifecycle?.cleanRunCount || 0) < requiredCleanRuns) continue;
182
+ await transport.updateIssueState(regressionCase.issue.repo, regressionCase.issue.number, "closed");
183
+ regressionCase.state = "resolved";
184
+ regressionCase.lifecycle = {
185
+ ...(regressionCase.lifecycle || {}),
186
+ resolvedAt: new Date().toISOString(),
187
+ };
188
+ changed = true;
189
+ actions.push({
190
+ type: "close_issue",
191
+ caseId: regressionCase.id,
192
+ issue: regressionCase.issue,
193
+ reason: "stability_window_clean",
194
+ status: "applied",
195
+ });
196
+ }
197
+ }
198
+
199
+ for (const action of report.workflow?.actions || []) {
200
+ if (action.type !== "create_case" || action.status !== "applied") continue;
201
+ const regressionCase = caseById.get(action.caseId);
202
+ if (!regressionCase || regressionCase.issue || workflowConfig.github.createIssues === "never") continue;
203
+ if (regressionCase.classification === "infra" && workflowConfig.evidence?.requireForIssueCreation) continue;
204
+ const repo = caseStore.issueRepo;
205
+ if (!repo) continue;
206
+ const issue = await transport.createIssue(repo, {
207
+ title: regressionCase.summary,
208
+ body: buildIssueBody(regressionCase, action),
209
+ labels: workflowConfig.github.issueLabels || workflowConfig.github.bugLabels || ["bug"],
210
+ });
211
+ if (issue?.number) {
212
+ regressionCase.issue = { repo, number: issue.number };
213
+ regressionCase.state = "active";
214
+ changed = true;
215
+ actions.push({
216
+ type: "create_issue",
217
+ caseId: regressionCase.id,
218
+ issue: regressionCase.issue,
219
+ reason: "new_confirmed_regression",
220
+ status: "applied",
221
+ });
222
+ }
223
+ }
224
+ return { changed };
225
+ }
226
+
227
+ async function maybeCreateGitHubTransport(workflowConfig, env) {
228
+ if (workflowConfig?.github?.read === false && !workflowConfig?.github?.write) return null;
229
+ return createDefaultGitHubIssueTransport(env, {
230
+ apiBaseUrl: workflowConfig?.github?.apiBaseUrl,
231
+ });
232
+ }
233
+
234
+ function buildIssueBody(regressionCase, action) {
235
+ return [
236
+ regressionCase.cause,
237
+ "",
238
+ "Testkit evidence:",
239
+ ...(action.tests || []).map((test) => `- ${test.service} ${test.type} ${test.path}: ${test.status}`),
240
+ ].join("\n");
241
+ }
242
+
243
+ function collectRefs(refs, text, pattern, kind) {
244
+ for (const match of String(text || "").matchAll(pattern)) {
245
+ refs.push({ raw: match[1], kind });
246
+ }
247
+ }
248
+
249
+ function normalizeIssueRef(ref, defaultRepo) {
250
+ const match = String(ref.raw || "").match(/^(?:(?<repo>[\w.-]+\/[\w.-]+))?#(?<number>\d+)$/u);
251
+ if (!match) return null;
252
+ const repo = match.groups.repo || defaultRepo;
253
+ const number = Number(match.groups.number);
254
+ if (!repo || !Number.isInteger(number)) return null;
255
+ return { kind: ref.kind, repo, number };
256
+ }
257
+
258
+ function summarizeWorkflowActions(actions) {
259
+ const summary = { applied: 0, suggested: 0, byType: {} };
260
+ for (const action of actions) {
261
+ if (action.status === "applied") summary.applied += 1;
262
+ if (action.status === "suggested") summary.suggested += 1;
263
+ summary.byType[action.type] = (summary.byType[action.type] || 0) + 1;
264
+ }
265
+ return summary;
266
+ }
@@ -295,23 +295,24 @@ function formatDiagnosis(diagnosis) {
295
295
  if (diagnosis.classifications?.length) {
296
296
  lines.push(`classification: ${diagnosis.classifications.join(", ")}`);
297
297
  }
298
- for (const entry of diagnosis.entries || []) {
299
- lines.push(`issue: ${entry.issue.repo}#${entry.issue.number}`);
298
+ for (const entry of diagnosis.cases || []) {
299
+ lines.push(`case: ${entry.id}`);
300
+ if (entry.issue) lines.push(`issue: ${entry.issue.repo}#${entry.issue.number}`);
300
301
  lines.push(`summary: ${entry.summary}`);
301
302
  if (entry.github?.url) lines.push(`url: ${entry.github.url}`);
302
303
  if (entry.github?.state) {
303
304
  lines.push(`github state: ${entry.github.state}${entry.github.cached ? " (cache)" : ""}`);
304
305
  }
305
- if (entry.syncStatus) lines.push(`catalog status: ${entry.syncStatus}`);
306
- if (entry.catalogFindings?.length) {
307
- for (const finding of entry.catalogFindings.slice(0, 3)) {
308
- lines.push(`catalog finding: ${finding.message}`);
306
+ if (entry.syncStatus) lines.push(`case status: ${entry.syncStatus}`);
307
+ if (entry.syncFindings?.length) {
308
+ for (const finding of entry.syncFindings.slice(0, 3)) {
309
+ lines.push(`case finding: ${finding.message}`);
309
310
  }
310
311
  }
311
312
  break;
312
313
  }
313
314
  if (diagnosis.status === "new_regression") {
314
- lines.push("next: review .testkit/results/latest.json drafts.newRegressions for a prepared catalog entry");
315
+ lines.push("next: review .testkit/results/latest.json drafts.newRegressions for a prepared case");
315
316
  }
316
317
  return lines;
317
318
  }