canary-test-cli 7.2.0 → 8.0.0

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 (59) hide show
  1. package/agents/skills/README.md +23 -4
  2. package/agents/skills/claude-code/canary-batwoman/SKILL.md +119 -0
  3. package/agents/skills/claude-code/canary-cassandra/SKILL.md +23 -16
  4. package/agents/skills/claude-code/canary-cassandra/scripts/cli.mjs +3 -1
  5. package/agents/skills/claude-code/canary-ci-ready/SKILL.md +20 -3
  6. package/agents/skills/claude-code/canary-fleet-health/SKILL.md +1 -0
  7. package/agents/skills/claude-code/canary-pr-guardian/SKILL.md +15 -0
  8. package/agents/skills/claude-code/canary-screech/SKILL.md +109 -0
  9. package/agents/skills/claude-code/canary-screech/scripts/blast.mjs +125 -0
  10. package/agents/skills/claude-code/canary-screech/scripts/cli.mjs +128 -0
  11. package/agents/skills/claude-code/canary-screech/scripts/cluster.mjs +97 -0
  12. package/agents/skills/claude-code/canary-screech/scripts/history.mjs +73 -0
  13. package/agents/skills/claude-code/canary-screech/scripts/redness.mjs +94 -0
  14. package/agents/skills/lib/parse-args.mjs +200 -139
  15. package/dist/engine/analysis/batwoman/audit.js +39 -0
  16. package/dist/engine/analysis/batwoman/closure.js +159 -0
  17. package/dist/engine/analysis/batwoman/gh-history.js +119 -0
  18. package/dist/engine/analysis/batwoman/probes.js +195 -0
  19. package/dist/engine/analysis/batwoman/registry.js +142 -0
  20. package/dist/engine/analysis/batwoman/render.js +194 -0
  21. package/dist/engine/analysis/batwoman/run-window.js +122 -0
  22. package/dist/engine/analysis/batwoman/text.js +84 -0
  23. package/dist/engine/analysis/batwoman/triggers.js +122 -0
  24. package/dist/engine/analysis/batwoman/verdict.js +64 -0
  25. package/dist/engine/analysis/cli.js +47 -14
  26. package/dist/engine/analysis/gh-flaky/gh-run-attempts.js +206 -0
  27. package/dist/engine/batwoman-cli.js +119 -0
  28. package/dist/engine/ci-ready-cli.js +71 -0
  29. package/dist/engine/cli-commands.js +46 -7
  30. package/dist/engine/cli.core.js +16 -0
  31. package/dist/engine/company-knowledge-cli.js +10 -2
  32. package/dist/engine/core/ci-ready.js +112 -0
  33. package/dist/engine/core/company-knowledge.js +8 -0
  34. package/dist/engine/core/migrator.js +147 -20
  35. package/dist/engine/core/permission-matrix.js +219 -0
  36. package/dist/engine/core/quality-scorer.js +13 -18
  37. package/dist/engine/core/scaling-curve.js +143 -0
  38. package/dist/engine/core/string-literals.js +3 -1
  39. package/dist/engine/core/vacuity-scanner.js +151 -6
  40. package/dist/engine/core/workflow-discovery.js +41 -23
  41. package/dist/engine/guardian/adjudication-github.js +136 -0
  42. package/dist/engine/guardian/adjudication.js +119 -340
  43. package/dist/engine/guardian/cli.js +180 -264
  44. package/dist/engine/guardian/coverage.js +2 -1
  45. package/dist/engine/guardian/diff-coverage/coverage-delta.js +162 -0
  46. package/dist/engine/guardian/diff-coverage/formats/cobertura.js +45 -1
  47. package/dist/engine/guardian/diff-coverage/orchestrator.js +25 -21
  48. package/dist/engine/guardian/diff-coverage/paths.js +5 -9
  49. package/dist/engine/guardian/diff-coverage/report-tier.js +88 -12
  50. package/dist/engine/guardian/diff-extractor.js +31 -32
  51. package/dist/engine/guardian/pr-check.js +262 -430
  52. package/dist/engine/guardian/pr-comment.js +35 -58
  53. package/dist/engine/guardian/weak-test.js +236 -0
  54. package/dist/engine/mcp-server.js +67 -4
  55. package/dist/engine/permission-matrix-cli.js +51 -0
  56. package/dist/engine/scaling-curve-cli.js +147 -0
  57. package/dist/engine/skills-cli.js +48 -32
  58. package/dist/engine/workflow-cli.js +85 -65
  59. package/package.json +1 -1
@@ -375,13 +375,31 @@ function braceDepths(code) {
375
375
  function declEnd(code, matches, i, depth) {
376
376
  if (depth === null)
377
377
  return matches[i + 1]?.index ?? code.length;
378
- const own = depth[matches[i].index] ?? 0;
378
+ const m = matches[i];
379
+ const own = depth[m.index] ?? 0;
380
+ let sibling = code.length;
379
381
  for (let j = i + 1; j < matches.length; j += 1) {
380
382
  const at = matches[j].index;
381
- if ((depth[at] ?? 0) <= own)
382
- return at;
383
+ if ((depth[at] ?? 0) <= own) {
384
+ sibling = at;
385
+ break;
386
+ }
387
+ }
388
+ // A `function` owns the text up to its next sibling. A `const`/`let`/`var`
389
+ // owns only its initializer (#871): inside a test body there is usually no
390
+ // later sibling, so bounding a bystander at one let it absorb the target
391
+ // call below it and read as reaching the target, silencing VAC-003. The
392
+ // statement ends at the first `;` or line break at the declaration's own
393
+ // brace depth, so an arrow helper's braced body still belongs to it.
394
+ if (m[1])
395
+ return sibling;
396
+ const from = m.index + m[0].length;
397
+ for (let k = from; k < sibling; k += 1) {
398
+ const ch = code[k];
399
+ if ((ch === ';' || ch === '\n') && (depth[k] ?? 0) <= own)
400
+ return k;
383
401
  }
384
- return code.length;
402
+ return sibling;
385
403
  }
386
404
  /**
387
405
  * Grow `targets` with local declarations that themselves reach a target, to a
@@ -513,6 +531,7 @@ function scanBlock(code, block, file, python, reaching, annotated, skipped, outO
513
531
  ...tautologies(lines, block, file, python),
514
532
  ...targetNeverInvoked(block, file, reaching, annotated, outOfBand),
515
533
  ...absenceOnly(lines, block, file, python, targets, skipped),
534
+ ...presenceOnBystander(lines, block, file, python, targets),
516
535
  ];
517
536
  }
518
537
  /**
@@ -580,6 +599,20 @@ function targetNeverInvoked(block, file, reaching, annotated, outOfBand) {
580
599
  * the write. Reported at the first absence assertion, the line an author adds a
581
600
  * precondition next to.
582
601
  */
602
+ /**
603
+ * A test title safe to put in a skip label: its first line, capped at 80.
604
+ *
605
+ * The title parser can mis-read a declaration and hand back the source after
606
+ * it (#860 -- whole `describe` bodies reached the summary line). Bounding the
607
+ * label here means a parser slip degrades to a truncated name, never a flood.
608
+ */
609
+ export function boundedTitle(name) {
610
+ const first = name.split('\n', 1)[0].trim();
611
+ return first.length > 80 ? `${first.slice(0, 80)}…` : first;
612
+ }
613
+ function skipLabel(rules, file, block) {
614
+ return `${file}:${block.line} ${rules} (${boundedTitle(block.name)})`;
615
+ }
583
616
  function absenceOnly(lines, block, file, python, targets, skipped) {
584
617
  if (targets === null)
585
618
  return [];
@@ -592,7 +625,7 @@ function absenceOnly(lines, block, file, python, targets, skipped) {
592
625
  // assertion style is one the vocabulary does not know. Both are "cannot
593
626
  // verify", so both are recorded rather than passed over in silence.
594
627
  skipped.push({
595
- name: `VAC-003 (${block.name})`,
628
+ name: skipLabel('VAC-003', file, block),
596
629
  reason: 'no recognised assertion, so absence-only could not be judged -- the test may assert nothing (LINT-006) or use an unrecognised assertion style',
597
630
  });
598
631
  return [];
@@ -605,6 +638,118 @@ function absenceOnly(lines, block, file, python, targets, skipped) {
605
638
  mk(file, assertions[0].line, 'VAC-003', 'warning', block.name, 'Every assertion in this test asserts an absence, and none of them observes the target.', 'Add one assertion proving the operation actually ran (exit code, returned value, a positive existence) -- otherwise the test passes identically when the code crashed before doing anything.'),
606
639
  ];
607
640
  }
641
+ /**
642
+ * Presence matchers that a value the test built for itself satisfies before the
643
+ * target ever runs. The negated-null forms belong here too, but on their own they
644
+ * also match `ABSENCE_ASSERTION` (via `.not.to*`), so an all-negated test stays
645
+ * VAC-003's -- see {@link presenceOnBystander}.
646
+ */
647
+ const TRIVIAL_PRESENCE_JS = /\.toBeDefined\s*\(\s*\)|\.toBeTruthy\s*\(\s*\)|\.not\s*\.\s*(?:toBeNull|toBeUndefined|toBeFalsy)\s*\(\s*\)/;
648
+ const TRIVIAL_PRESENCE_PY = /^assert\s+([A-Za-z_][\w.]*?)(?:\s+is\s+not\s+None)?\s*(?:,|$)/;
649
+ const SUBJECT_ROOT = /^([A-Za-z_$][\w$]*)(?:\.[A-Za-z_$][\w$]*)*$/;
650
+ function subjectRoot(expr) {
651
+ return expr ? (SUBJECT_ROOT.exec(normalize(expr))?.[1] ?? null) : null;
652
+ }
653
+ /** The root identifier a trivial presence assertion observes, or null. */
654
+ function presenceSubject(text, python) {
655
+ const t = text.trim();
656
+ if (python)
657
+ return subjectRoot(TRIVIAL_PRESENCE_PY.exec(t)?.[1]);
658
+ return TRIVIAL_PRESENCE_JS.test(t) ? subjectRoot(expectArgument(t)) : null;
659
+ }
660
+ /**
661
+ * Every binding of a name inside the body: `const x = rhs` / `x = rhs` (JS) or
662
+ * `x = rhs` (pytest), with the RHS bounded to its own line.
663
+ *
664
+ * Deliberately NOT `closeOverLocals`: that bounds a declaration at its next
665
+ * sibling, so a `const subs = [...]` inside a test absorbs the rest of the test
666
+ * -- including the target call -- and `subs` reads as reaching the target. That
667
+ * is exactly the bystander this rule exists to see.
668
+ */
669
+ function bodyBindings(lines, python) {
670
+ const re = python
671
+ ? /^\s*([A-Za-z_]\w*)\s*=(?!=)\s*(.*)$/
672
+ : /^\s*(const\s+|let\s+|var\s+)?([A-Za-z_$][\w$]*)\s*=(?!=)\s*(.*)$/;
673
+ const out = [];
674
+ for (const { text } of lines) {
675
+ const m = re.exec(text);
676
+ if (!m)
677
+ continue;
678
+ if (python)
679
+ out.push({ name: m[1], rhs: m[2], declared: true });
680
+ else
681
+ out.push({ name: m[2], rhs: m[3], declared: m[1] !== undefined });
682
+ }
683
+ return out;
684
+ }
685
+ /** Is `rhs` a whole statement on its line? A dangling bracket means it is not. */
686
+ function balanced(rhs) {
687
+ let d = 0;
688
+ for (const c of rhs) {
689
+ if (c === '(' || c === '[' || c === '{')
690
+ d += 1;
691
+ else if (c === ')' || c === ']' || c === '}')
692
+ d -= 1;
693
+ }
694
+ return d === 0;
695
+ }
696
+ /**
697
+ * VAC-005 -- every assertion is a trivially true presence check, and its subject
698
+ * is a BYSTANDER: a name the test itself bound, never from anything reaching the
699
+ * target (#870). The mirror image of VAC-003:
700
+ *
701
+ * ```js
702
+ * const subs = [{ id: 's1' }];
703
+ * matchSubmissions([], subs);
704
+ * expect(subs).toBeDefined(); // true before the call, true after it
705
+ * ```
706
+ *
707
+ * The bystander clause is the rule, for the same reason VAC-003's is:
708
+ * `const r = target(); expect(r).toBeDefined()` DOES observe the target, and a
709
+ * throw from it would fail the test. Anything the rule cannot prove is a
710
+ * bystander -- a name bound in a hook or at module scope, a multi-line RHS --
711
+ * yields no finding, so the error direction is always a miss, never a false
712
+ * accusation.
713
+ */
714
+ function presenceOnBystander(lines, block, file, python, targets) {
715
+ if (targets === null)
716
+ return [];
717
+ const anyAssertion = python ? PY_ASSERTION : JS_ASSERTION;
718
+ const absence = python ? PY_ABSENCE_ASSERTION : ABSENCE_ASSERTION;
719
+ const assertions = lines.filter((l) => anyAssertion.test(l.text));
720
+ if (assertions.length === 0)
721
+ return [];
722
+ // All-absence (the negated-null forms included) is VAC-003's to report.
723
+ if (assertions.every((l) => absence.test(l.text)))
724
+ return [];
725
+ const subjects = assertions.map((l) => presenceSubject(l.text, python));
726
+ if (subjects.some((s) => s === null))
727
+ return [];
728
+ const bindings = bodyBindings(lines, python);
729
+ if (bindings.some((b) => !balanced(b.rhs)))
730
+ return [];
731
+ // The body's own names are removed first -- `closeOverLocals` over-attributes
732
+ // them (see `bodyBindings`) -- then re-derived here from their real RHS, to a
733
+ // fixpoint so `const r = save(); const s = r;` still reaches.
734
+ const bound = new Set(bindings.map((b) => b.name));
735
+ const reaching = new Set([...targets].filter((t) => !bound.has(t)));
736
+ let grew = true;
737
+ while (grew) {
738
+ grew = false;
739
+ for (const b of bindings) {
740
+ if (reaching.has(b.name) || !mentionsAny(b.rhs, reaching))
741
+ continue;
742
+ reaching.add(b.name);
743
+ grew = true;
744
+ }
745
+ }
746
+ const bystander = (name) => !reaching.has(name) && bindings.some((b) => b.name === name && b.declared);
747
+ if (!subjects.every((s) => bystander(s)))
748
+ return [];
749
+ return [
750
+ mk(file, assertions[0].line, 'VAC-005', 'warning', block.name, `Every assertion is a presence check on \`${subjects[0]}\`, a value the test built itself; it holds whether or not the target ran.`, 'Assert on what the target returns or changes -- e.g. `expect(target(input)).toEqual(expected)` -- so the test fails when the target is wrong.'),
751
+ ];
752
+ }
608
753
  /** A zero-denominator result that names why it could not measure. */
609
754
  function unreadable(path, reason) {
610
755
  return { checked: 0, findings: [], skipped: [{ name: path, reason }] };
@@ -713,7 +858,7 @@ function scanAllBlocks(ctx, blocks, skipped) {
713
858
  // real one, and #705 is explicit that a suppressed inference and a passing
714
859
  // check must not look alike.
715
860
  skipped.push({
716
- name: `${outOfBand ? 'VAC-003' : 'VAC-002/VAC-003'} (${block.name})`,
861
+ name: skipLabel(outOfBand ? 'VAC-003' : 'VAC-002/VAC-003', ctx.path, block),
717
862
  reason: outOfBand
718
863
  ? 'target reached out of band (subprocess or bare dynamic import), so VAC-002 is answered but no symbol exists for absence-only to observe'
719
864
  : 'target unresolvable: no @covers annotation and no first-party relative import to infer from',
@@ -305,6 +305,45 @@ export class WorkflowMapping {
305
305
  export class WorkflowDiscoveryError extends Error {
306
306
  }
307
307
  // ---------------------------------------------------------------------------
308
+ // Jira request helpers
309
+ // ---------------------------------------------------------------------------
310
+ // Split out of `WorkflowDiscovery.fetchJira` to pay down its complexity. Kept
311
+ // at module level so the class keeps the Python port's method set, and each
312
+ // stays at or under the perf-ratchet warning threshold (10).
313
+ /** Read the ATLASSIAN_* env vars, refusing when any is unset or empty. */
314
+ function jiraCredentials() {
315
+ const baseUrl = rstripChar(process.env['ATLASSIAN_URL'] ?? '', '/');
316
+ const user = process.env['ATLASSIAN_USER'] ?? '';
317
+ const token = process.env['ATLASSIAN_TOKEN'] ?? '';
318
+ if (!pyTruthy(baseUrl) || !pyTruthy(user) || !pyTruthy(token)) {
319
+ throw new WorkflowDiscoveryError('Jira credentials not configured. Set ATLASSIAN_URL, ' +
320
+ 'ATLASSIAN_USER, and ATLASSIAN_TOKEN environment variables.\n' +
321
+ 'Tip: add them to .canary/company.local.json or your shell profile.');
322
+ }
323
+ const auth = Buffer.from(`${user}:${token}`).toString('base64');
324
+ return {
325
+ baseUrl,
326
+ headers: { Authorization: `Basic ${auth}`, Accept: 'application/json' },
327
+ };
328
+ }
329
+ /**
330
+ * The issue-type list from the `issuetypes` response. An error object (Jira
331
+ * answers a missing project with `errorMessages` in a 200) is refused; any
332
+ * other non-list shape yields no issue types.
333
+ */
334
+ function jiraIssueTypeList(issueTypesRaw, projectKey) {
335
+ if (Array.isArray(issueTypesRaw)) {
336
+ return issueTypesRaw;
337
+ }
338
+ if (typeof issueTypesRaw === 'object' &&
339
+ issueTypesRaw !== null &&
340
+ 'errorMessages' in issueTypesRaw) {
341
+ throw new WorkflowDiscoveryError(`Jira project ${pyRepr(projectKey)} not found or access denied: ` +
342
+ `${pyRepr(issueTypesRaw['errorMessages'])}`);
343
+ }
344
+ return [];
345
+ }
346
+ // ---------------------------------------------------------------------------
308
347
  // Main class
309
348
  // ---------------------------------------------------------------------------
310
349
  /** Discovers and caches per-project issue-workflow mappings. */
@@ -399,34 +438,13 @@ export class WorkflowDiscovery {
399
438
  // -- private: Jira ---------------------------------------------------------
400
439
  /** Python: `WorkflowDiscovery._fetch_jira`. */
401
440
  async fetchJira(projectKey) {
402
- const baseUrl = rstripChar(process.env['ATLASSIAN_URL'] ?? '', '/');
403
- const user = process.env['ATLASSIAN_USER'] ?? '';
404
- const token = process.env['ATLASSIAN_TOKEN'] ?? '';
405
- if (!pyTruthy(baseUrl) || !pyTruthy(user) || !pyTruthy(token)) {
406
- throw new WorkflowDiscoveryError('Jira credentials not configured. Set ATLASSIAN_URL, ' +
407
- 'ATLASSIAN_USER, and ATLASSIAN_TOKEN environment variables.\n' +
408
- 'Tip: add them to .canary/company.local.json or your shell profile.');
409
- }
410
- const auth = Buffer.from(`${user}:${token}`).toString('base64');
411
- const headers = {
412
- Authorization: `Basic ${auth}`,
413
- Accept: 'application/json',
414
- };
441
+ const { baseUrl, headers } = jiraCredentials();
415
442
  // Capture the URL so ticket_updater can use it without requiring the env var.
416
443
  const discoveredBaseUrl = baseUrl;
417
444
  // 1. Get issue types for this project.
418
445
  const issueTypesRaw = await this.jiraGet(`${baseUrl}/rest/api/3/project/${projectKey}/issuetypes`, headers);
419
- if (!Array.isArray(issueTypesRaw) &&
420
- typeof issueTypesRaw === 'object' &&
421
- issueTypesRaw !== null &&
422
- 'errorMessages' in issueTypesRaw) {
423
- throw new WorkflowDiscoveryError(`Jira project ${pyRepr(projectKey)} not found or access denied: ` +
424
- `${pyRepr(issueTypesRaw['errorMessages'])}`);
425
- }
446
+ const list = jiraIssueTypeList(issueTypesRaw, projectKey);
426
447
  const issueTypes = [];
427
- const list = Array.isArray(issueTypesRaw)
428
- ? issueTypesRaw
429
- : [];
430
448
  for (const itRaw of list) {
431
449
  const itId = String(pyGet(itRaw, 'id', ''));
432
450
  const itName = String(pyGet(itRaw, 'name', ''));
@@ -0,0 +1,136 @@
1
+ /**
2
+ * GitHub evidence for derived adjudication (ADR 0025, #938).
3
+ *
4
+ * The network seam behind `adjudication.ts`: merged PRs in a window (GraphQL
5
+ * search), the guardian sticky's edit history (REST comments + GraphQL
6
+ * `userContentEdits`), and the merged file patches (REST). The sticky is found
7
+ * with the author-aware {@link findSticky} (#931), never by marker substring.
8
+ *
9
+ * Network lives ONLY in {@link GitHubAdjudicationSource}'s default seams;
10
+ * tests use {@link FakeAdjudicationSource} or inject `read` / `graphql`.
11
+ */
12
+ import { readAllPages, restPageReader } from './github-paging.js';
13
+ import { findSticky } from './pr-comment.js';
14
+ const API = 'https://api.github.com';
15
+ /** The most merged PRs one report walks (bounds API cost and rate limits). */
16
+ const MERGED_PR_LIMIT = 300;
17
+ /** In-memory {@link AdjudicationSource} for tests. */
18
+ export class FakeAdjudicationSource {
19
+ init;
20
+ constructor(init) {
21
+ this.init = init;
22
+ }
23
+ async mergedPrs() {
24
+ return this.init.merged;
25
+ }
26
+ async stickyHistory(pr) {
27
+ const revisions = this.init.stickies?.[pr];
28
+ return revisions === undefined ? null : { revisions };
29
+ }
30
+ async prFiles(pr) {
31
+ return this.init.files?.[pr] ?? [];
32
+ }
33
+ }
34
+ /**
35
+ * Sticky bodies oldest to newest. GitHub returns edits newest first and each
36
+ * `diff` holds the whole body at that revision. A truncated or redacted
37
+ * history is `null` (disclosed as "no edit history"), never a shorter list.
38
+ */
39
+ export function revisionsFrom(body, edits) {
40
+ if (!edits)
41
+ return null;
42
+ if (edits.totalCount === 0)
43
+ return [body];
44
+ const diffs = edits.nodes.map((n) => n.diff);
45
+ if (diffs.length < edits.totalCount || diffs.includes(null))
46
+ return null;
47
+ return [...diffs.reverse(), body];
48
+ }
49
+ const SEARCH_QUERY = `query($q: String!, $after: String) {
50
+ search(query: $q, type: ISSUE, first: 100, after: $after) {
51
+ issueCount pageInfo { hasNextPage endCursor }
52
+ nodes { ... on PullRequest { number } } } }`;
53
+ const EDITS_QUERY = `query($id: ID!) { node(id: $id) { ... on IssueComment {
54
+ userContentEdits(first: 100) { totalCount nodes { diff } } } } }`;
55
+ function defaultGraphql(token) {
56
+ return async (query, variables) => {
57
+ const resp = await fetch(`${API}/graphql`, {
58
+ method: 'POST',
59
+ headers: {
60
+ Authorization: `Bearer ${token}`,
61
+ 'User-Agent': 'canary-pr-guardian',
62
+ },
63
+ body: JSON.stringify({ query, variables }),
64
+ });
65
+ const json = (await resp.json());
66
+ if (!resp.ok || json.errors || !json.data) {
67
+ throw new Error(`GitHub GraphQL ${resp.status}: ${JSON.stringify(json)}`);
68
+ }
69
+ return json.data;
70
+ };
71
+ }
72
+ /** The real {@link AdjudicationSource} over GitHub REST + GraphQL. */
73
+ export class GitHubAdjudicationSource {
74
+ repo;
75
+ read;
76
+ graphql;
77
+ constructor(repo, token, seams = {}) {
78
+ this.repo = repo;
79
+ this.read =
80
+ seams.read ??
81
+ restPageReader({
82
+ Authorization: `Bearer ${token}`,
83
+ Accept: 'application/vnd.github+json',
84
+ 'User-Agent': 'canary-pr-guardian',
85
+ }, (status, url) => new Error(`GitHub API ${status}: ${url}`));
86
+ this.graphql = seams.graphql ?? defaultGraphql(token);
87
+ }
88
+ /** Pages the search; refuses a window past {@link MERGED_PR_LIMIT} rather than truncating. */
89
+ async mergedPrs(since) {
90
+ const q = `repo:${this.repo} is:pr is:merged merged:>=${since}`;
91
+ const numbers = [];
92
+ let after = null;
93
+ do {
94
+ const data = await this.graphql(SEARCH_QUERY, { q, after });
95
+ const search = data['search'];
96
+ if (search.issueCount > MERGED_PR_LIMIT) {
97
+ throw new Error(`${search.issueCount} merged PRs since ${since} exceeds the ` +
98
+ `${MERGED_PR_LIMIT}-PR bound; narrow --days`);
99
+ }
100
+ numbers.push(...search.nodes.map((n) => n.number));
101
+ after = search.pageInfo?.hasNextPage ? search.pageInfo.endCursor : null;
102
+ } while (after !== null);
103
+ return numbers;
104
+ }
105
+ async stickyHistory(pr) {
106
+ const url = `${API}/repos/${this.repo}/issues/${pr}/comments`;
107
+ const comments = (await readAllPages(url, this.read));
108
+ const sticky = findSticky(comments);
109
+ if (sticky === null)
110
+ return null;
111
+ const data = await this.graphql(EDITS_QUERY, { id: sticky.node_id });
112
+ const node = data['node'];
113
+ return { revisions: revisionsFrom(sticky.body, node?.userContentEdits) };
114
+ }
115
+ async prFiles(pr) {
116
+ const url = `${API}/repos/${this.repo}/pulls/${pr}/files`;
117
+ return (await readAllPages(url, this.read));
118
+ }
119
+ }
120
+ /** Walk the merged PRs; PRs without a sticky are scanned but carry no evidence. */
121
+ export async function collectEvidence(source, since) {
122
+ const merged = await source.mergedPrs(since);
123
+ const evidence = [];
124
+ for (const number of merged) {
125
+ const history = await source.stickyHistory(number);
126
+ if (history === null)
127
+ continue;
128
+ evidence.push({
129
+ number,
130
+ revisions: history.revisions,
131
+ files: await source.prFiles(number),
132
+ });
133
+ }
134
+ return { evidence, scanned: merged.length };
135
+ }
136
+ //# sourceMappingURL=adjudication-github.js.map