@elench/testkit 0.1.43 → 0.1.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,739 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { execFile } from "child_process";
4
+ import { promisify } from "util";
5
+ import { findMatchingKnownFailureEntries } from "./index.mjs";
6
+
7
+ const execFileAsync = promisify(execFile);
8
+ const DEFAULT_CACHE_TTL_SECONDS = 15 * 60;
9
+ const CACHE_SCHEMA_VERSION = 1;
10
+ const CACHE_PATH = [".testkit", "known-failures", "github-issues-cache.json"];
11
+ const MODES = new Set(["off", "warn", "error"]);
12
+
13
+ export function normalizeKnownFailureIssueValidationConfig(value) {
14
+ if (value == null) return null;
15
+ if (!value || typeof value !== "object") {
16
+ throw new Error("testkit.setup.ts reporting.issueValidation must be an object");
17
+ }
18
+
19
+ const provider = normalizeOptionalString(value.provider) || "github";
20
+ if (provider !== "github") {
21
+ throw new Error('testkit.setup.ts reporting.issueValidation.provider must be "github"');
22
+ }
23
+
24
+ const mode = normalizeOptionalString(value.mode) || "warn";
25
+ if (!MODES.has(mode)) {
26
+ throw new Error(
27
+ 'testkit.setup.ts reporting.issueValidation.mode must be one of: off, warn, error'
28
+ );
29
+ }
30
+
31
+ const cacheTtlSeconds =
32
+ value.cacheTtlSeconds == null
33
+ ? DEFAULT_CACHE_TTL_SECONDS
34
+ : normalizePositiveInteger(
35
+ value.cacheTtlSeconds,
36
+ "testkit.setup.ts reporting.issueValidation.cacheTtlSeconds"
37
+ );
38
+
39
+ return {
40
+ provider,
41
+ mode,
42
+ cacheTtlSeconds,
43
+ };
44
+ }
45
+
46
+ export async function validateKnownFailureIssues({
47
+ productDir,
48
+ document,
49
+ runArtifact,
50
+ statusArtifact,
51
+ config,
52
+ gitMetadata = null,
53
+ transport = null,
54
+ now = Date.now(),
55
+ }) {
56
+ const normalizedConfig = normalizeKnownFailureIssueValidationConfig(config);
57
+ if (!document || !normalizedConfig || normalizedConfig.mode === "off") return null;
58
+
59
+ const checks = collectObservedKnownFailureEntries(document, runArtifact, statusArtifact);
60
+ const issueNumbersByRepo = groupIssueNumbersByRepo(document.entries);
61
+ const repoSlug = gitMetadata?.repoSlug || null;
62
+ const remoteUrl = gitMetadata?.remoteUrl || null;
63
+ const cache = loadIssueCache(productDir);
64
+ const cacheResolution = resolveIssueCache(cache, issueNumbersByRepo, normalizedConfig, now);
65
+ const availabilityFindings = [];
66
+ let issuesByRepo = cacheResolution.issuesByRepo;
67
+
68
+ if (cacheResolution.missingByRepo.size > 0) {
69
+ const client = transport || (await createDefaultGitHubIssueTransport());
70
+ if (client) {
71
+ try {
72
+ const fetchedIssues = await fetchIssuesByRepo(client, cacheResolution.missingByRepo);
73
+ issuesByRepo = mergeIssuesByRepo(issuesByRepo, fetchedIssues);
74
+ updateIssueCache(cache, fetchedIssues, now);
75
+ writeIssueCache(productDir, cache);
76
+ } catch (error) {
77
+ issuesByRepo = applyStaleCacheFallback(
78
+ issuesByRepo,
79
+ cacheResolution.staleByRepo,
80
+ availabilityFindings
81
+ );
82
+ const severity = normalizedConfig.mode === "error" ? "error" : "warning";
83
+ availabilityFindings.push({
84
+ code: "validation_unavailable",
85
+ severity,
86
+ message: `GitHub issue validation unavailable: ${formatErrorMessage(error)}`,
87
+ });
88
+ }
89
+ } else {
90
+ issuesByRepo = applyStaleCacheFallback(
91
+ issuesByRepo,
92
+ cacheResolution.staleByRepo,
93
+ availabilityFindings
94
+ );
95
+ const severity = normalizedConfig.mode === "error" ? "error" : "warning";
96
+ availabilityFindings.push({
97
+ code: "validation_unavailable",
98
+ severity,
99
+ message:
100
+ "GitHub issue validation unavailable: set GH_TOKEN/GITHUB_TOKEN or install/authenticate gh",
101
+ });
102
+ }
103
+ }
104
+
105
+ const entries = document.entries.map((entry) => {
106
+ const observed = checks.get(entry.id) || createObservedCheck(entry);
107
+ const issueData = issuesByRepo.get(entry.issue.repo)?.get(entry.issue.number) || null;
108
+ return buildIssueValidationEntry({
109
+ entry,
110
+ observed,
111
+ issueData,
112
+ });
113
+ });
114
+
115
+ const globalFindings = [...availabilityFindings];
116
+ if (document.issueRepo && repoSlug && document.issueRepo !== repoSlug) {
117
+ globalFindings.push({
118
+ code: "detected_repo_mismatch",
119
+ severity: normalizedConfig.mode === "error" ? "error" : "warning",
120
+ message:
121
+ `Known failures issueRepo ${document.issueRepo} does not match detected repo ${repoSlug}` +
122
+ (remoteUrl ? ` (${remoteUrl})` : ""),
123
+ });
124
+ }
125
+
126
+ const summary = buildIssueValidationSummary(entries, globalFindings);
127
+ return {
128
+ schemaVersion: 1,
129
+ provider: "github",
130
+ mode: normalizedConfig.mode,
131
+ checkedAt: new Date(now).toISOString(),
132
+ repo: {
133
+ detected: repoSlug,
134
+ remoteUrl,
135
+ configured: document.issueRepo || null,
136
+ },
137
+ availability: {
138
+ hasFreshData: cacheResolution.missingByRepo.size === 0 || availabilityFindings.length === 0,
139
+ usedCachedFallback: availabilityFindings.some((finding) => finding.code === "used_stale_cache"),
140
+ },
141
+ findings: globalFindings,
142
+ summary,
143
+ entries,
144
+ };
145
+ }
146
+
147
+ export function shouldFailKnownFailureIssueValidation(result) {
148
+ if (!result || result.mode !== "error") return false;
149
+ return (result.summary?.errors || 0) > 0;
150
+ }
151
+
152
+ export function buildKnownFailureIssueValidationSummaryLines(result) {
153
+ if (!result) return [];
154
+
155
+ const parts = [];
156
+ const byCode = result.summary?.byCode || {};
157
+ if (byCode.closed_but_failing) {
158
+ parts.push(`${byCode.closed_but_failing} closed issue${pluralSuffix(byCode.closed_but_failing)} still failing`);
159
+ }
160
+ if (byCode.issue_missing) {
161
+ parts.push(`${byCode.issue_missing} missing issue reference${pluralSuffix(byCode.issue_missing)}`);
162
+ }
163
+ if (byCode.open_not_reproduced) {
164
+ parts.push(`${byCode.open_not_reproduced} open issue${pluralSuffix(byCode.open_not_reproduced)} not reproduced`);
165
+ }
166
+ if (byCode.closed_not_reproduced) {
167
+ parts.push(`${byCode.closed_not_reproduced} closed issue${pluralSuffix(byCode.closed_not_reproduced)} not reproduced`);
168
+ }
169
+ if (byCode.title_mismatch) {
170
+ parts.push(`${byCode.title_mismatch} title mismatch${pluralSuffix(byCode.title_mismatch)}`);
171
+ }
172
+ if (byCode.state_mismatch) {
173
+ parts.push(`${byCode.state_mismatch} state mismatch${pluralSuffix(byCode.state_mismatch)}`);
174
+ }
175
+ if (byCode.detected_repo_mismatch) {
176
+ parts.push(`${byCode.detected_repo_mismatch} repo mismatch${pluralSuffix(byCode.detected_repo_mismatch)}`);
177
+ }
178
+ if (byCode.validation_unavailable) {
179
+ parts.push("GitHub validation unavailable");
180
+ }
181
+ if (byCode.used_stale_cache) {
182
+ parts.push("used stale GitHub cache");
183
+ }
184
+
185
+ if (parts.length === 0) return [];
186
+
187
+ return [
188
+ "",
189
+ "Known-failure issues:",
190
+ ...parts.map((part) => ` - ${part}`),
191
+ ];
192
+ }
193
+
194
+ export function parseGitHubRepoSlug(remoteUrl) {
195
+ const normalized = normalizeOptionalString(remoteUrl);
196
+ if (!normalized) return null;
197
+
198
+ let match = normalized.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/i);
199
+ if (match) return `${match[1]}/${match[2]}`;
200
+
201
+ match = normalized.match(/^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/i);
202
+ if (match) return `${match[1]}/${match[2]}`;
203
+
204
+ match = normalized.match(/^ssh:\/\/git@github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/i);
205
+ if (match) return `${match[1]}/${match[2]}`;
206
+
207
+ return null;
208
+ }
209
+
210
+ async function createDefaultGitHubIssueTransport() {
211
+ const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN || null;
212
+ if (token) {
213
+ return {
214
+ type: "token",
215
+ async fetchRepoIssues(repo, numbers) {
216
+ return fetchRepoIssuesViaToken(repo, numbers, token);
217
+ },
218
+ };
219
+ }
220
+
221
+ try {
222
+ await execFileAsync("gh", ["auth", "status"], {
223
+ encoding: "utf8",
224
+ env: process.env,
225
+ maxBuffer: 1024 * 1024,
226
+ });
227
+ return {
228
+ type: "gh",
229
+ async fetchRepoIssues(repo, numbers) {
230
+ return fetchRepoIssuesViaGh(repo, numbers);
231
+ },
232
+ };
233
+ } catch {
234
+ return null;
235
+ }
236
+ }
237
+
238
+ async function fetchIssuesByRepo(client, issueNumbersByRepo) {
239
+ const issuesByRepo = new Map();
240
+
241
+ for (const [repo, numbers] of issueNumbersByRepo.entries()) {
242
+ const issueMap = new Map();
243
+ const chunks = chunkValues(numbers, 40);
244
+ for (const chunk of chunks) {
245
+ const fetched = await client.fetchRepoIssues(repo, chunk);
246
+ for (const [number, issue] of fetched.entries()) {
247
+ issueMap.set(number, issue);
248
+ }
249
+ }
250
+ issuesByRepo.set(repo, issueMap);
251
+ }
252
+
253
+ return issuesByRepo;
254
+ }
255
+
256
+ async function fetchRepoIssuesViaToken(repo, numbers, token) {
257
+ const query = buildIssueQuery(repo, numbers);
258
+ const response = await fetch("https://api.github.com/graphql", {
259
+ method: "POST",
260
+ headers: {
261
+ "Content-Type": "application/json",
262
+ Accept: "application/vnd.github+json",
263
+ Authorization: `Bearer ${token}`,
264
+ "X-GitHub-Api-Version": "2022-11-28",
265
+ },
266
+ body: JSON.stringify({ query }),
267
+ });
268
+
269
+ const payload = await response.json().catch(() => null);
270
+ if (!response.ok) {
271
+ throw new Error(
272
+ `GitHub GraphQL request failed with ${response.status}${
273
+ payload?.message ? `: ${payload.message}` : ""
274
+ }`
275
+ );
276
+ }
277
+ if (Array.isArray(payload?.errors) && payload.errors.length > 0) {
278
+ throw new Error(payload.errors.map((error) => error.message).join("; "));
279
+ }
280
+ return normalizeGraphqlIssuesResponse(repo, numbers, payload?.data);
281
+ }
282
+
283
+ async function fetchRepoIssuesViaGh(repo, numbers) {
284
+ const query = buildIssueQuery(repo, numbers);
285
+ const { stdout } = await execFileAsync(
286
+ "gh",
287
+ ["api", "graphql", "-f", `query=${query}`],
288
+ {
289
+ encoding: "utf8",
290
+ env: process.env,
291
+ maxBuffer: 1024 * 1024,
292
+ }
293
+ );
294
+ const payload = JSON.parse(stdout);
295
+ if (Array.isArray(payload?.errors) && payload.errors.length > 0) {
296
+ throw new Error(payload.errors.map((error) => error.message).join("; "));
297
+ }
298
+ return normalizeGraphqlIssuesResponse(repo, numbers, payload?.data);
299
+ }
300
+
301
+ function buildIssueQuery(repo, numbers) {
302
+ const [owner, name] = repo.split("/");
303
+ return `
304
+ query TestkitKnownFailureIssues {
305
+ repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(name)}) {
306
+ ${numbers
307
+ .map(
308
+ (number) => `
309
+ issue_${number}: issue(number: ${number}) {
310
+ number
311
+ title
312
+ state
313
+ url
314
+ }
315
+ `
316
+ )
317
+ .join("\n")}
318
+ }
319
+ }
320
+ `;
321
+ }
322
+
323
+ function normalizeGraphqlIssuesResponse(repo, numbers, data) {
324
+ const map = new Map();
325
+ const repository = data?.repository || null;
326
+ for (const number of numbers) {
327
+ const issue = repository?.[`issue_${number}`] || null;
328
+ if (!issue) {
329
+ map.set(number, {
330
+ repo,
331
+ number,
332
+ exists: false,
333
+ title: null,
334
+ state: null,
335
+ url: null,
336
+ checkedAt: null,
337
+ source: "github",
338
+ });
339
+ continue;
340
+ }
341
+
342
+ map.set(number, {
343
+ repo,
344
+ number: issue.number,
345
+ exists: true,
346
+ title: normalizeOptionalString(issue.title),
347
+ state: normalizeOptionalString(issue.state),
348
+ url: normalizeOptionalString(issue.url),
349
+ checkedAt: null,
350
+ source: "github",
351
+ });
352
+ }
353
+ return map;
354
+ }
355
+
356
+ function collectObservedKnownFailureEntries(document, runArtifact, statusArtifact) {
357
+ const observedTests = collectObservedTests(runArtifact, statusArtifact);
358
+ const checks = new Map(document.entries.map((entry) => [entry.id, createObservedCheck(entry)]));
359
+
360
+ for (const test of observedTests) {
361
+ const matchedEntries = findMatchingKnownFailureEntries(document, test);
362
+ for (const entry of matchedEntries) {
363
+ const observed = checks.get(entry.id) || createObservedCheck(entry);
364
+ observed.matchedTests += 1;
365
+ if (test.status === "failed") {
366
+ observed.failedTests += 1;
367
+ }
368
+ checks.set(entry.id, observed);
369
+ }
370
+ }
371
+
372
+ return checks;
373
+ }
374
+
375
+ function collectObservedTests(runArtifact, statusArtifact) {
376
+ if (Array.isArray(statusArtifact?.tests)) {
377
+ return statusArtifact.tests;
378
+ }
379
+
380
+ const tests = [];
381
+ for (const service of runArtifact?.services || []) {
382
+ for (const suite of service.suites || []) {
383
+ for (const file of suite.files || []) {
384
+ tests.push({
385
+ service: service.name,
386
+ type: suite.type,
387
+ path: file.path,
388
+ status: file.status,
389
+ error: file.error || null,
390
+ failureDetails: Array.isArray(file.failureDetails) ? file.failureDetails : [],
391
+ });
392
+ }
393
+ }
394
+ }
395
+ return tests;
396
+ }
397
+
398
+ function createObservedCheck(entry) {
399
+ return {
400
+ id: entry.id,
401
+ matchedTests: 0,
402
+ failedTests: 0,
403
+ };
404
+ }
405
+
406
+ function groupIssueNumbersByRepo(entries) {
407
+ const map = new Map();
408
+ for (const entry of entries) {
409
+ if (!map.has(entry.issue.repo)) {
410
+ map.set(entry.issue.repo, []);
411
+ }
412
+ const values = map.get(entry.issue.repo);
413
+ if (!values.includes(entry.issue.number)) {
414
+ values.push(entry.issue.number);
415
+ }
416
+ }
417
+
418
+ for (const numbers of map.values()) {
419
+ numbers.sort((left, right) => left - right);
420
+ }
421
+ return map;
422
+ }
423
+
424
+ function buildIssueValidationEntry({ entry, observed, issueData }) {
425
+ const findings = [];
426
+ const normalizedIssueState = normalizeIssueState(issueData?.state);
427
+
428
+ if (issueData && issueData.exists === false) {
429
+ findings.push({
430
+ code: "issue_missing",
431
+ severity: "error",
432
+ message: `Known failure ${entry.id} references missing issue #${entry.issue.number} in ${entry.issue.repo}`,
433
+ });
434
+ }
435
+
436
+ if (issueData?.exists && issueData.title && issueData.title !== entry.title) {
437
+ findings.push({
438
+ code: "title_mismatch",
439
+ severity: "error",
440
+ message: `Known failure ${entry.id} title does not match issue #${entry.issue.number}`,
441
+ });
442
+ }
443
+
444
+ if (issueData?.exists && normalizedIssueState && normalizedIssueState !== entry.state) {
445
+ findings.push({
446
+ code: "state_mismatch",
447
+ severity: "error",
448
+ message: `Known failure ${entry.id} state ${entry.state} does not match issue #${entry.issue.number} state ${normalizedIssueState}`,
449
+ });
450
+ }
451
+
452
+ if (issueData?.exists && observed.failedTests > 0 && normalizedIssueState === "closed") {
453
+ findings.push({
454
+ code: "closed_but_failing",
455
+ severity: "error",
456
+ message: `Known failure ${entry.id} still fails but issue #${entry.issue.number} is closed`,
457
+ });
458
+ }
459
+
460
+ if (issueData?.exists && observed.failedTests === 0 && observed.matchedTests > 0) {
461
+ if (normalizedIssueState === "open") {
462
+ findings.push({
463
+ code: "open_not_reproduced",
464
+ severity: "warning",
465
+ message: `Known failure ${entry.id} did not reproduce but issue #${entry.issue.number} is open`,
466
+ });
467
+ } else if (normalizedIssueState === "closed") {
468
+ findings.push({
469
+ code: "closed_not_reproduced",
470
+ severity: "warning",
471
+ message: `Known failure ${entry.id} did not reproduce and issue #${entry.issue.number} is closed`,
472
+ });
473
+ }
474
+ }
475
+
476
+ return {
477
+ id: entry.id,
478
+ title: entry.title,
479
+ issue: entry.issue,
480
+ observed: {
481
+ matchedTests: observed.matchedTests,
482
+ failedTests: observed.failedTests,
483
+ reproduced: observed.failedTests > 0,
484
+ },
485
+ github: issueData
486
+ ? {
487
+ exists: issueData.exists,
488
+ title: issueData.title,
489
+ state: issueData.state,
490
+ url: issueData.url,
491
+ checkedAt: issueData.checkedAt,
492
+ cached: issueData.source === "cache",
493
+ }
494
+ : {
495
+ exists: null,
496
+ title: null,
497
+ state: null,
498
+ url: null,
499
+ checkedAt: null,
500
+ cached: false,
501
+ },
502
+ findings,
503
+ status: resolveIssueValidationStatus(issueData, observed, findings),
504
+ };
505
+ }
506
+
507
+ function resolveIssueValidationStatus(issueData, observed, findings) {
508
+ if (!issueData) return observed.matchedTests > 0 ? "validation_unavailable" : "not_observed";
509
+ if (issueData.exists === false) return "issue_missing";
510
+ const normalizedState = normalizeIssueState(issueData.state);
511
+ if (observed.failedTests > 0 && normalizedState === "closed") return "closed_but_failing";
512
+ if (observed.failedTests > 0 && normalizedState === "open") return "open_and_failing";
513
+ if (observed.matchedTests > 0 && normalizedState === "open") return "open_not_reproduced";
514
+ if (observed.matchedTests > 0 && normalizedState === "closed") return "closed_not_reproduced";
515
+ if (findings.length > 0) return "metadata_mismatch";
516
+ return "not_observed";
517
+ }
518
+
519
+ function buildIssueValidationSummary(entries, globalFindings) {
520
+ const byCode = {};
521
+ const byStatus = {};
522
+ let errors = 0;
523
+ let warnings = 0;
524
+
525
+ for (const finding of globalFindings) {
526
+ byCode[finding.code] = (byCode[finding.code] || 0) + 1;
527
+ if (finding.severity === "error") errors += 1;
528
+ if (finding.severity === "warning") warnings += 1;
529
+ }
530
+
531
+ for (const entry of entries) {
532
+ byStatus[entry.status] = (byStatus[entry.status] || 0) + 1;
533
+ for (const finding of entry.findings) {
534
+ byCode[finding.code] = (byCode[finding.code] || 0) + 1;
535
+ if (finding.severity === "error") errors += 1;
536
+ if (finding.severity === "warning") warnings += 1;
537
+ }
538
+ }
539
+
540
+ return {
541
+ entries: entries.length,
542
+ observedEntries: entries.filter((entry) => entry.observed.matchedTests > 0).length,
543
+ errors,
544
+ warnings,
545
+ byCode,
546
+ byStatus,
547
+ };
548
+ }
549
+
550
+ function loadIssueCache(productDir) {
551
+ const filePath = getIssueCachePath(productDir);
552
+ if (!fs.existsSync(filePath)) {
553
+ return {
554
+ schemaVersion: CACHE_SCHEMA_VERSION,
555
+ entries: {},
556
+ };
557
+ }
558
+
559
+ try {
560
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
561
+ if (parsed?.schemaVersion !== CACHE_SCHEMA_VERSION || typeof parsed.entries !== "object") {
562
+ return {
563
+ schemaVersion: CACHE_SCHEMA_VERSION,
564
+ entries: {},
565
+ };
566
+ }
567
+ return parsed;
568
+ } catch {
569
+ return {
570
+ schemaVersion: CACHE_SCHEMA_VERSION,
571
+ entries: {},
572
+ };
573
+ }
574
+ }
575
+
576
+ function resolveIssueCache(cache, issueNumbersByRepo, config, now) {
577
+ const issuesByRepo = new Map();
578
+ const missingByRepo = new Map();
579
+ const staleByRepo = new Map();
580
+ const ttlMs = config.cacheTtlSeconds * 1000;
581
+
582
+ for (const [repo, numbers] of issueNumbersByRepo.entries()) {
583
+ const cachedIssues = new Map();
584
+ const missing = [];
585
+ const stale = [];
586
+
587
+ for (const number of numbers) {
588
+ const key = buildIssueCacheKey(repo, number);
589
+ const cached = cache.entries[key];
590
+ if (!cached) {
591
+ missing.push(number);
592
+ continue;
593
+ }
594
+
595
+ const checkedAt = Date.parse(cached.checkedAt);
596
+ const normalized = {
597
+ repo,
598
+ number,
599
+ exists: Boolean(cached.exists),
600
+ title: normalizeOptionalString(cached.title),
601
+ state: normalizeOptionalString(cached.state),
602
+ url: normalizeOptionalString(cached.url),
603
+ checkedAt: cached.checkedAt || null,
604
+ source: "cache",
605
+ };
606
+
607
+ if (!Number.isFinite(checkedAt) || now - checkedAt > ttlMs) {
608
+ stale.push(number);
609
+ cachedIssues.set(number, normalized);
610
+ continue;
611
+ }
612
+
613
+ cachedIssues.set(number, normalized);
614
+ }
615
+
616
+ issuesByRepo.set(repo, cachedIssues);
617
+ if (missing.length > 0 || stale.length > 0) {
618
+ missingByRepo.set(repo, [...new Set([...missing, ...stale])].sort((a, b) => a - b));
619
+ }
620
+ if (stale.length > 0) {
621
+ staleByRepo.set(repo, stale.sort((a, b) => a - b));
622
+ }
623
+ }
624
+
625
+ return {
626
+ issuesByRepo,
627
+ missingByRepo,
628
+ staleByRepo,
629
+ };
630
+ }
631
+
632
+ function updateIssueCache(cache, issuesByRepo, now) {
633
+ cache.schemaVersion = CACHE_SCHEMA_VERSION;
634
+ cache.entries = cache.entries || {};
635
+ const checkedAt = new Date(now).toISOString();
636
+
637
+ for (const [repo, issues] of issuesByRepo.entries()) {
638
+ for (const [number, issue] of issues.entries()) {
639
+ issue.checkedAt = checkedAt;
640
+ cache.entries[buildIssueCacheKey(repo, number)] = {
641
+ exists: issue.exists,
642
+ title: issue.title,
643
+ state: issue.state,
644
+ url: issue.url,
645
+ checkedAt,
646
+ };
647
+ }
648
+ }
649
+ }
650
+
651
+ function writeIssueCache(productDir, cache) {
652
+ const filePath = getIssueCachePath(productDir);
653
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
654
+ fs.writeFileSync(filePath, `${JSON.stringify(cache, null, 2)}\n`);
655
+ }
656
+
657
+ function getIssueCachePath(productDir) {
658
+ return path.join(productDir, ...CACHE_PATH);
659
+ }
660
+
661
+ function applyStaleCacheFallback(issuesByRepo, staleByRepo, findings) {
662
+ let usedStale = false;
663
+ for (const [repo, numbers] of staleByRepo.entries()) {
664
+ const issues = issuesByRepo.get(repo);
665
+ if (!issues) continue;
666
+ for (const number of numbers) {
667
+ if (issues.has(number)) {
668
+ usedStale = true;
669
+ }
670
+ }
671
+ }
672
+ if (usedStale) {
673
+ findings.push({
674
+ code: "used_stale_cache",
675
+ severity: "warning",
676
+ message: "Used stale cached GitHub issue metadata because fresh validation failed",
677
+ });
678
+ }
679
+ return issuesByRepo;
680
+ }
681
+
682
+ function mergeIssuesByRepo(left, right) {
683
+ const merged = new Map();
684
+ for (const [repo, issues] of left.entries()) {
685
+ merged.set(repo, new Map(issues.entries()));
686
+ }
687
+
688
+ for (const [repo, issues] of right.entries()) {
689
+ if (!merged.has(repo)) {
690
+ merged.set(repo, new Map());
691
+ }
692
+ const mergedIssues = merged.get(repo);
693
+ for (const [number, issue] of issues.entries()) {
694
+ mergedIssues.set(number, issue);
695
+ }
696
+ }
697
+
698
+ return merged;
699
+ }
700
+
701
+ function buildIssueCacheKey(repo, number) {
702
+ return `${repo}#${number}`;
703
+ }
704
+
705
+ function normalizeIssueState(value) {
706
+ const normalized = normalizeOptionalString(value);
707
+ if (!normalized) return null;
708
+ return normalized.toLowerCase();
709
+ }
710
+
711
+ function normalizeOptionalString(value) {
712
+ if (typeof value !== "string") return null;
713
+ const normalized = value.trim();
714
+ return normalized.length > 0 ? normalized : null;
715
+ }
716
+
717
+ function normalizePositiveInteger(value, label) {
718
+ if (!Number.isInteger(value) || value <= 0) {
719
+ throw new Error(`${label} must be a positive integer`);
720
+ }
721
+ return value;
722
+ }
723
+
724
+ function chunkValues(values, size) {
725
+ const chunks = [];
726
+ for (let index = 0; index < values.length; index += size) {
727
+ chunks.push(values.slice(index, index + size));
728
+ }
729
+ return chunks;
730
+ }
731
+
732
+ function pluralSuffix(value) {
733
+ return value === 1 ? "" : "s";
734
+ }
735
+
736
+ function formatErrorMessage(error) {
737
+ if (error instanceof Error) return error.message;
738
+ return String(error);
739
+ }