knodin 0.5.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 (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +590 -0
  3. package/dist/bin/cli.js +1704 -0
  4. package/dist/src/agent-integration.js +250 -0
  5. package/dist/src/artifact-refresh.js +81 -0
  6. package/dist/src/cli-args.js +267 -0
  7. package/dist/src/cli-model.js +324 -0
  8. package/dist/src/compact-structural.js +96 -0
  9. package/dist/src/competitive-constraints.js +20 -0
  10. package/dist/src/competitive-manifest.js +330 -0
  11. package/dist/src/competitive-measurement.js +183 -0
  12. package/dist/src/competitive-runner.js +453 -0
  13. package/dist/src/competitive-sandbox.js +108 -0
  14. package/dist/src/context-export.js +422 -0
  15. package/dist/src/context.js +102 -0
  16. package/dist/src/docs-sections.js +141 -0
  17. package/dist/src/doctor.js +380 -0
  18. package/dist/src/engine/ann-hnsw.js +271 -0
  19. package/dist/src/engine/embeddings.js +193 -0
  20. package/dist/src/engine/file-walker.js +43 -0
  21. package/dist/src/engine/index.js +13030 -0
  22. package/dist/src/engine/perf.js +115 -0
  23. package/dist/src/engine/prune.js +112 -0
  24. package/dist/src/engine/source-policy.js +69 -0
  25. package/dist/src/engine/sqlite.js +71 -0
  26. package/dist/src/engine/symbol-delete.js +58 -0
  27. package/dist/src/failure-diagnosis.js +590 -0
  28. package/dist/src/fleet.js +7 -0
  29. package/dist/src/git-executable.js +31 -0
  30. package/dist/src/graph-query-health.js +115 -0
  31. package/dist/src/index-activity.js +125 -0
  32. package/dist/src/init-progress-worker.js +107 -0
  33. package/dist/src/init-progress.js +155 -0
  34. package/dist/src/init.js +985 -0
  35. package/dist/src/lifecycle-health.js +213 -0
  36. package/dist/src/lsp-readonly.js +217 -0
  37. package/dist/src/output-compression.js +629 -0
  38. package/dist/src/output-telemetry.js +359 -0
  39. package/dist/src/pr-triage.js +638 -0
  40. package/dist/src/relationship-adapters.js +370 -0
  41. package/dist/src/release-attestation.js +533 -0
  42. package/dist/src/repair-progress-worker.js +121 -0
  43. package/dist/src/repair-progress.js +262 -0
  44. package/dist/src/repository-init-process.js +173 -0
  45. package/dist/src/repository-management.js +1089 -0
  46. package/dist/src/response-budget.js +184 -0
  47. package/dist/src/server.js +53 -0
  48. package/dist/src/system-config.js +615 -0
  49. package/dist/src/terminal-help.js +83 -0
  50. package/dist/src/tools/knodin-tools.js +1438 -0
  51. package/dist/src/tools/reckon-tools.js +5 -0
  52. package/dist/src/update-policy.js +944 -0
  53. package/dist/src/update-trust.js +503 -0
  54. package/dist/src/version.js +13 -0
  55. package/dist/src/visualization.js +162 -0
  56. package/dist/src/wait-for-fresh.js +98 -0
  57. package/dist/src/worktree-lifecycle.js +231 -0
  58. package/docs/CLI.md +39 -0
  59. package/docs/COMMAND-OUTPUT-COMPRESSION.md +194 -0
  60. package/docs/DEAD-CODE-AND-IMPACT.md +27 -0
  61. package/docs/DOCTOR-AND-UPDATES.md +84 -0
  62. package/docs/INDEXING-POLICY-AND-PROVENANCE.md +37 -0
  63. package/docs/INSTALLATION.md +208 -0
  64. package/docs/MCP.md +100 -0
  65. package/docs/PT-ACCESS-RECOMMENDATION.md +91 -0
  66. package/docs/RELEASE-0.3-EVIDENCE.md +73 -0
  67. package/docs/REPOSITORIES-AND-WORKTREES.md +81 -0
  68. package/docs/SIGNED-UPDATES.md +146 -0
  69. package/docs/SYSTEMS-AND-RELATIONSHIPS.md +45 -0
  70. package/docs/TELEMETRY.md +42 -0
  71. package/docs/releases/0.3.0.md +46 -0
  72. package/docs/releases/0.4.0.md +68 -0
  73. package/docs/releases/0.4.1.md +28 -0
  74. package/docs/releases/0.4.2.md +27 -0
  75. package/docs/releases/0.4.3.md +23 -0
  76. package/docs/releases/0.5.0.md +29 -0
  77. package/package.json +110 -0
  78. package/schemas/release-attestation-v1.schema.json +210 -0
  79. package/tree-sitter-prisma.wasm +0 -0
  80. package/tree-sitter-sql.wasm +0 -0
  81. package/tree-sitter-xml.wasm +0 -0
@@ -0,0 +1,638 @@
1
+ /**
2
+ * PR triage — a thin local wrapper around the user's already-authenticated
3
+ * `gh` CLI. No stored credentials, no auth logic here: this only shells out to
4
+ * `gh` and joins its output onto the engine's existing `query("impact", ...)`
5
+ * and `map()` results.
6
+ *
7
+ * Shipped CLI-first under R7; R22 additionally exposes this same pipeline
8
+ * through the `knodin` MCP gateway's `prs` operation (the R7 "CLI-only, never
9
+ * wire into knodin-tools" ruling is superseded — the no-stored-creds property
10
+ * it protected is unchanged). The MCP path must never throw into the stdio
11
+ * transport, so it goes through `ghUnavailableReason()` (non-throwing) rather
12
+ * than `ensureGhAvailable()` (throwing, still used by the CLI's hard-fail).
13
+ */
14
+ import child_process from "node:child_process";
15
+ /** Resolve the `gh` binary to invoke. Tests override via `RECKON_GH_BIN` to
16
+ * point at a stub script fixture — no network in tests. */
17
+ function ghBin() {
18
+ return process.env.RECKON_GH_BIN || "gh";
19
+ }
20
+ const STALE_DAYS = 30;
21
+ const PR_LIST_FIELDS = "number,title,updatedAt,reviewDecision,statusCheckRollup,files,headRefName";
22
+ const PR_AUDIT_FIELDS = `${PR_LIST_FIELDS},baseRefName,state,mergeCommit,url,isDraft`;
23
+ const GH_MISSING_MSG = "knodin prs requires the GitHub CLI ('gh') on PATH — install it, then run 'gh auth login'.";
24
+ const GH_UNAUTH_MSG = "knodin prs requires an authenticated 'gh' — run 'gh auth login' (gh auth status failed).";
25
+ /** Match a bounded branch glob without compiling user input as a regular expression. */
26
+ export function matchesBranchGlob(value, pattern) {
27
+ if (pattern.length > 256)
28
+ return false;
29
+ let valueIndex = 0;
30
+ let patternIndex = 0;
31
+ let starIndex = -1;
32
+ let starValueIndex = 0;
33
+ while (valueIndex < value.length) {
34
+ if (patternIndex < pattern.length &&
35
+ (pattern[patternIndex] === "?" || pattern[patternIndex] === value[valueIndex])) {
36
+ valueIndex++;
37
+ patternIndex++;
38
+ }
39
+ else if (patternIndex < pattern.length && pattern[patternIndex] === "*") {
40
+ starIndex = patternIndex++;
41
+ starValueIndex = valueIndex;
42
+ }
43
+ else if (starIndex >= 0) {
44
+ patternIndex = starIndex + 1;
45
+ valueIndex = ++starValueIndex;
46
+ }
47
+ else
48
+ return false;
49
+ }
50
+ while (patternIndex < pattern.length && pattern[patternIndex] === "*")
51
+ patternIndex++;
52
+ return patternIndex === pattern.length;
53
+ }
54
+ /**
55
+ * Non-throwing gh-availability probe: returns a one-line reason string when
56
+ * `gh` is missing from PATH or unauthenticated, or `null` when it's usable.
57
+ * The MCP path (R22) uses this so a `gh` problem surfaces as a structured
58
+ * `{ error }` result instead of an exception the stdio transport can't
59
+ * interpret. Shares its message text with {@link ensureGhAvailable}.
60
+ */
61
+ export function ghUnavailableReason() {
62
+ const bin = ghBin();
63
+ const res = child_process.spawnSync(bin, ["auth", "status"], {
64
+ encoding: "utf-8",
65
+ env: process.env,
66
+ });
67
+ if (res.error)
68
+ return GH_MISSING_MSG;
69
+ if (res.status !== 0)
70
+ return GH_UNAUTH_MSG;
71
+ return null;
72
+ }
73
+ /**
74
+ * Hard-fails fast (one-line message, no stack trace expected from callers)
75
+ * when `gh` isn't on PATH or isn't authenticated. Required before the single
76
+ * `gh pr list` call per the R7 spec. Retained for the CLI's hard-fail path;
77
+ * the MCP path uses {@link ghUnavailableReason} instead.
78
+ */
79
+ export function ensureGhAvailable() {
80
+ const reason = ghUnavailableReason();
81
+ if (reason)
82
+ throw new Error(reason);
83
+ }
84
+ /** Single `gh pr list` call for open PRs — NO per-PR `gh` calls (per spec). */
85
+ export function fetchOpenPrs(repoPath, limit = 50) {
86
+ const bin = ghBin();
87
+ const res = child_process.spawnSync(bin, ["pr", "list", "--json", PR_LIST_FIELDS, "--limit", String(limit)], { cwd: repoPath, encoding: "utf-8", env: process.env });
88
+ if (res.error) {
89
+ throw new Error(GH_MISSING_MSG);
90
+ }
91
+ if (res.status !== 0) {
92
+ const stderr = (res.stderr ?? "").toString().trim();
93
+ throw new Error(`knodin prs: 'gh pr list' failed${stderr ? `: ${stderr}` : ""}`);
94
+ }
95
+ const stdout = (res.stdout ?? "").toString().trim();
96
+ if (!stdout)
97
+ return [];
98
+ return JSON.parse(stdout);
99
+ }
100
+ /** `gh pr view <n>` fields — same set as {@link PR_LIST_FIELDS} plus `state`,
101
+ * needed to filter out a closed/merged PR that `gh pr view` will happily
102
+ * return even though it isn't "open". */
103
+ const PR_VIEW_FIELDS = `${PR_LIST_FIELDS},state`;
104
+ /**
105
+ * Single-PR lookup that isn't bounded by `gh pr list`'s `--limit` page: a PR
106
+ * that exists and is open but falls outside a small `fetchOpenPrs` page (e.g.
107
+ * an older PR when there are more than `limit` open PRs) must still be found.
108
+ * Returns `null` — never throws — both when the PR number doesn't exist at
109
+ * all (`gh pr view`'s failure mode for a bad number) and when it exists but
110
+ * isn't currently OPEN (closed/merged), matching "not found among open PRs".
111
+ */
112
+ export function fetchOpenPr(repoPath, prNumber) {
113
+ const bin = ghBin();
114
+ const res = child_process.spawnSync(bin, ["pr", "view", String(prNumber), "--json", PR_VIEW_FIELDS], { cwd: repoPath, encoding: "utf-8", env: process.env });
115
+ if (res.error || res.status !== 0)
116
+ return null;
117
+ const stdout = (res.stdout ?? "").toString().trim();
118
+ if (!stdout)
119
+ return null;
120
+ const pr = JSON.parse(stdout);
121
+ if (pr.state !== "OPEN")
122
+ return null;
123
+ return pr;
124
+ }
125
+ /** Collapses a PR's `statusCheckRollup` (CheckRun/StatusContext entries, mixed
126
+ * `conclusion`/`state`/`status` vocab across GitHub's two check APIs) into one
127
+ * of `pass | pending | fail | none`. */
128
+ export function computeCiStatus(rollup) {
129
+ if (!rollup || rollup.length === 0)
130
+ return "none";
131
+ const FAIL_CONCLUSIONS = new Set([
132
+ "FAILURE",
133
+ "CANCELLED",
134
+ "TIMED_OUT",
135
+ "ERROR",
136
+ "ACTION_REQUIRED",
137
+ "STARTUP_FAILURE",
138
+ ]);
139
+ let pending = false;
140
+ for (const entry of rollup) {
141
+ const conclusion = (entry.conclusion ?? "").toUpperCase();
142
+ const state = (entry.state ?? "").toUpperCase();
143
+ const status = (entry.status ?? "").toUpperCase();
144
+ if (FAIL_CONCLUSIONS.has(conclusion) || state === "FAILURE" || state === "ERROR") {
145
+ return "fail";
146
+ }
147
+ if (state === "PENDING" || (status && status !== "COMPLETED")) {
148
+ pending = true;
149
+ }
150
+ }
151
+ return pending ? "pending" : "pass";
152
+ }
153
+ /**
154
+ * Classifies a PR per the R7 spec: BLOCKED (changes requested or CI fail)
155
+ * beats PENDING (CI running) beats STALE (>30d untouched) beats READY (CI
156
+ * pass and review approved/none-required).
157
+ */
158
+ export function classifyPr(pr, ci, now = Date.now()) {
159
+ if (ci === "fail" || pr.reviewDecision === "CHANGES_REQUESTED")
160
+ return "BLOCKED";
161
+ if (ci === "pending")
162
+ return "PENDING";
163
+ const updatedMs = Date.parse(pr.updatedAt);
164
+ const ageDays = (now - updatedMs) / (1000 * 60 * 60 * 24);
165
+ if (Number.isFinite(ageDays) && ageDays > STALE_DAYS)
166
+ return "STALE";
167
+ return "READY";
168
+ }
169
+ /**
170
+ * Shared join for a PR's changed files → `query("impact", ...)` blast radius →
171
+ * `map()` community membership. Returns `null` when NONE of the PR's changed
172
+ * files are indexed (deps/workflow-only changes), which both public wrappers
173
+ * render as their respective "nothing indexed" shape. Names/counts are both
174
+ * derived from this one traversal so the two wrappers can never disagree.
175
+ */
176
+ async function joinPrImpact(pr, engine, repoPath) {
177
+ const changedPaths = (pr.files ?? []).map((f) => f.path).filter(Boolean);
178
+ if (changedPaths.length === 0)
179
+ return null;
180
+ const indexedPaths = [];
181
+ for (const filePath of changedPaths) {
182
+ const summary = await engine.query("file_summary", filePath, repoPath);
183
+ if (summary.count > 0)
184
+ indexedPaths.push(filePath);
185
+ }
186
+ if (indexedPaths.length === 0)
187
+ return null;
188
+ const impactResult = await engine.query("impact", indexedPaths.join(","), repoPath, undefined, undefined, undefined, undefined, undefined, { mode: "file" });
189
+ const impactedFiles = new Set(indexedPaths);
190
+ for (const row of impactResult.results) {
191
+ if (row.file)
192
+ impactedFiles.add(row.file);
193
+ }
194
+ const mapResult = await engine.map(repoPath, "standard");
195
+ const communities = [];
196
+ for (const community of mapResult.communities) {
197
+ const files = community.files ?? [];
198
+ if (files.some((f) => impactedFiles.has(f)))
199
+ communities.push(community.name);
200
+ }
201
+ return { symbolCount: impactResult.count, files: [...impactedFiles], communities };
202
+ }
203
+ /**
204
+ * Joins a PR's changed files onto `query("impact", ...)` and `map()`
205
+ * community membership. Files not present in the index (deps, workflows, …)
206
+ * are dropped before the join; if NONE of a PR's files are indexed the whole
207
+ * impact is reported as `"–"`, matching graphify's dep-only-PR row.
208
+ */
209
+ export async function computePrImpact(pr, engine, repoPath) {
210
+ const joined = await joinPrImpact(pr, engine, repoPath);
211
+ if (!joined)
212
+ return { symbols: "–", communities: "–" };
213
+ return { symbols: joined.symbolCount, communities: joined.communities.length };
214
+ }
215
+ /**
216
+ * Detailed single-PR impact (R22's `get_pr_impact` equivalent): the impacted
217
+ * file list and community **names**, not just counts. Returns empty lists when
218
+ * none of the PR's changed files are indexed.
219
+ */
220
+ export async function computePrImpactDetail(pr, engine, repoPath) {
221
+ const joined = await joinPrImpact(pr, engine, repoPath);
222
+ if (!joined)
223
+ return { files: [], communities: [] };
224
+ return { files: joined.files, communities: joined.communities };
225
+ }
226
+ const STATUS_RANK = {
227
+ READY: 0,
228
+ PENDING: 1,
229
+ STALE: 2,
230
+ BLOCKED: 3,
231
+ };
232
+ /** Numeric sort key for an impact — `"–"` (unindexed/dep-only) sorts as the
233
+ * smallest, simplest possible change. */
234
+ function impactSortKey(impact) {
235
+ return impact.symbols === "–" ? 0 : impact.symbols;
236
+ }
237
+ /**
238
+ * Runs the full R7 pipeline: hard-fail check, single `gh pr list` call,
239
+ * per-PR classification + impact join, then sorts READY-small-impact first,
240
+ * then PENDING, then STALE, then BLOCKED.
241
+ *
242
+ * `skipGhCheck` lets a caller that already probed gh availability itself
243
+ * (the MCP dispatcher, via {@link ghUnavailableReason}) skip this function's
244
+ * own {@link ensureGhAvailable} call — otherwise a single `knodin prs` MCP
245
+ * call spawns `gh auth status` twice. Defaults to `false` so the CLI's
246
+ * existing standalone hard-fail behavior is unchanged.
247
+ */
248
+ export async function triagePrs(repoPath, engine, limit = 50, skipGhCheck = false) {
249
+ if (!skipGhCheck)
250
+ ensureGhAvailable();
251
+ const prs = fetchOpenPrs(repoPath, limit);
252
+ const triaged = [];
253
+ for (const pr of prs) {
254
+ const ci = computeCiStatus(pr.statusCheckRollup);
255
+ const status = classifyPr(pr, ci);
256
+ const impact = await computePrImpact(pr, engine, repoPath);
257
+ triaged.push({
258
+ number: pr.number,
259
+ title: pr.title,
260
+ status,
261
+ ci,
262
+ updatedAt: pr.updatedAt,
263
+ impact,
264
+ });
265
+ }
266
+ triaged.sort((a, b) => {
267
+ const rankDiff = STATUS_RANK[a.status] - STATUS_RANK[b.status];
268
+ if (rankDiff !== 0)
269
+ return rankDiff;
270
+ return impactSortKey(a.impact) - impactSortKey(b.impact);
271
+ });
272
+ return triaged;
273
+ }
274
+ /**
275
+ * Single-PR detail path (R22): looks up `prNumber` directly via `gh pr view`
276
+ * (see {@link fetchOpenPr}) — NOT via `fetchOpenPrs(...).find(...)`, since
277
+ * that's bounded by `--limit` (default 50) and would falsely report "not
278
+ * found" for a real, open PR that simply falls outside that page — and
279
+ * returns the detailed impact (file list + community names). Assumes gh
280
+ * availability was already checked by the caller (the MCP dispatcher probes
281
+ * {@link ghUnavailableReason} first). Returns a structured `{ error }` —
282
+ * never throws — when the PR isn't open (missing entirely, or closed/merged).
283
+ */
284
+ export async function triagePrDetail(repoPath, engine, prNumber,
285
+ // Kept for call-site compatibility (the MCP dispatcher and CLI both still
286
+ // pass a `limit`), but unused now that lookup goes through `fetchOpenPr`
287
+ // (`gh pr view`) rather than a `--limit`-bounded `gh pr list` page.
288
+ _limit = 50) {
289
+ const pr = fetchOpenPr(repoPath, prNumber);
290
+ if (!pr)
291
+ return { error: `knodin prs: PR #${prNumber} not found among open PRs` };
292
+ const ci = computeCiStatus(pr.statusCheckRollup);
293
+ const status = classifyPr(pr, ci);
294
+ const impact = await computePrImpactDetail(pr, engine, repoPath);
295
+ return { number: pr.number, title: pr.title, status, ci, updatedAt: pr.updatedAt, impact };
296
+ }
297
+ function ghText(repoPath, args) {
298
+ const result = child_process.spawnSync(ghBin(), args, {
299
+ cwd: repoPath,
300
+ encoding: "utf-8",
301
+ env: process.env,
302
+ });
303
+ if (result.error)
304
+ return {
305
+ value: null,
306
+ error: result.error instanceof Error ? result.error.message : String(result.error),
307
+ };
308
+ if (result.status !== 0)
309
+ return {
310
+ value: null,
311
+ error: (result.stderr ?? "").toString().trim() || `gh ${args.join(" ")} failed`,
312
+ };
313
+ return { value: (result.stdout ?? "").toString().trim(), error: null };
314
+ }
315
+ function revisionCommits(repoPath, base, head) {
316
+ if (!base && !head)
317
+ return { commits: null, error: null };
318
+ if (!base || !head)
319
+ return { commits: null, error: "both --base and --head are required for revision auditing" };
320
+ const result = child_process.spawnSync("git", ["rev-list", `${base}..${head}`], {
321
+ cwd: repoPath,
322
+ encoding: "utf-8",
323
+ });
324
+ if (result.status !== 0)
325
+ return {
326
+ commits: null,
327
+ error: (result.stderr ?? "").toString().trim() || `unable to resolve ${base}..${head}`,
328
+ };
329
+ return {
330
+ commits: new Set((result.stdout ?? "").toString().split(/\r?\n/).filter(Boolean)),
331
+ error: null,
332
+ };
333
+ }
334
+ function rulesetPatternMatches(pattern, branch, defaultBranch) {
335
+ if (pattern === "~ALL")
336
+ return true;
337
+ if (pattern === "~DEFAULT_BRANCH")
338
+ return defaultBranch === branch;
339
+ const ref = `refs/heads/${branch}`;
340
+ return (pattern === branch ||
341
+ pattern === ref ||
342
+ matchesBranchGlob(branch, pattern) ||
343
+ matchesBranchGlob(ref, pattern));
344
+ }
345
+ function rulesetAppliesToBranch(ruleset, branch, defaultBranch) {
346
+ if (ruleset.target !== "branch" || ruleset.enforcement !== "active")
347
+ return false;
348
+ const names = ruleset.conditions?.ref_name;
349
+ const excluded = (names?.exclude ?? []).some((pattern) => rulesetPatternMatches(pattern, branch, defaultBranch));
350
+ if (excluded)
351
+ return false;
352
+ const included = names?.include ?? [];
353
+ return (included.length === 0 ||
354
+ included.some((pattern) => rulesetPatternMatches(pattern, branch, defaultBranch)));
355
+ }
356
+ function rulesetRequiredChecks(repoPath, repository, branch, defaultBranch) {
357
+ const listed = ghText(repoPath, ["api", `repos/${repository}/rulesets?includes_parents=true`]);
358
+ if (listed.error)
359
+ return { checks: null, error: listed.error };
360
+ let summaries;
361
+ try {
362
+ summaries = JSON.parse(listed.value || "[]");
363
+ if (!Array.isArray(summaries))
364
+ throw new Error("expected an array");
365
+ }
366
+ catch (error) {
367
+ return {
368
+ checks: null,
369
+ error: `invalid ruleset list JSON (${error instanceof Error ? error.message : String(error)})`,
370
+ };
371
+ }
372
+ const checks = [];
373
+ for (const summary of summaries) {
374
+ if (summary.id === undefined)
375
+ continue;
376
+ const detail = ghText(repoPath, [
377
+ "api",
378
+ `repos/${repository}/rulesets/${summary.id}?includes_parents=true`,
379
+ ]);
380
+ if (detail.error)
381
+ return { checks: null, error: `ruleset ${summary.id}: ${detail.error}` };
382
+ let ruleset;
383
+ try {
384
+ ruleset = JSON.parse(detail.value || "{}");
385
+ }
386
+ catch (error) {
387
+ return {
388
+ checks: null,
389
+ error: `ruleset ${summary.id}: invalid JSON (${error instanceof Error ? error.message : String(error)})`,
390
+ };
391
+ }
392
+ if (!rulesetAppliesToBranch(ruleset, branch, defaultBranch))
393
+ continue;
394
+ for (const rule of ruleset.rules ?? []) {
395
+ if (rule.type === "required_status_checks") {
396
+ for (const check of rule.parameters?.required_status_checks ?? []) {
397
+ checks.push({
398
+ ...check,
399
+ source: "ruleset",
400
+ rulesetId: ruleset.id ?? summary.id,
401
+ rulesetName: ruleset.name ?? summary.name ?? null,
402
+ ruleType: rule.type,
403
+ });
404
+ }
405
+ }
406
+ if (rule.type === "workflows") {
407
+ for (const workflow of rule.parameters?.workflows ?? []) {
408
+ checks.push({
409
+ ...workflow,
410
+ context: workflow.path ?? null,
411
+ source: "ruleset",
412
+ rulesetId: ruleset.id ?? summary.id,
413
+ rulesetName: ruleset.name ?? summary.name ?? null,
414
+ ruleType: rule.type,
415
+ });
416
+ }
417
+ }
418
+ }
419
+ }
420
+ return { checks, error: null };
421
+ }
422
+ function auditSupplement(repoPath, repository, pr, defaultBranch) {
423
+ const errors = [];
424
+ let requiredChecks = null;
425
+ const required = ghText(repoPath, [
426
+ "api",
427
+ `repos/${repository}/branches/${encodeURIComponent(pr.baseRefName ?? "")}/protection/required_status_checks`,
428
+ ]);
429
+ if (required.error?.includes("HTTP 404")) {
430
+ const fallback = rulesetRequiredChecks(repoPath, repository, pr.baseRefName ?? "", defaultBranch);
431
+ if (fallback.error)
432
+ errors.push(`PR #${pr.number} required checks: classic protection unavailable (${required.error}); rulesets: ${fallback.error}`);
433
+ else
434
+ requiredChecks = fallback.checks;
435
+ }
436
+ else if (required.error)
437
+ errors.push(`PR #${pr.number} required checks: ${required.error}`);
438
+ else {
439
+ try {
440
+ const parsed = JSON.parse(required.value || "{}");
441
+ requiredChecks = parsed.checks ?? parsed.contexts ?? [];
442
+ }
443
+ catch (error) {
444
+ errors.push(`PR #${pr.number} required checks: invalid JSON (${error instanceof Error ? error.message : String(error)})`);
445
+ }
446
+ }
447
+ const [owner, name] = repository.split("/", 2);
448
+ const threads = ghText(repoPath, [
449
+ "api",
450
+ "graphql",
451
+ "-f",
452
+ "query=query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){reviewThreads(first:100){nodes{isResolved isOutdated}pageInfo{hasNextPage}}}}}",
453
+ "-F",
454
+ `owner=${owner}`,
455
+ "-F",
456
+ `name=${name}`,
457
+ "-F",
458
+ `number=${pr.number}`,
459
+ ]);
460
+ let unresolvedActionableThreads = null;
461
+ if (threads.error)
462
+ errors.push(`PR #${pr.number} review threads: ${threads.error}`);
463
+ else {
464
+ try {
465
+ const parsed = JSON.parse(threads.value || "{}");
466
+ const reviewThreads = parsed.data?.repository?.pullRequest?.reviewThreads;
467
+ if (reviewThreads?.pageInfo?.hasNextPage)
468
+ errors.push(`PR #${pr.number} review threads: incomplete response exceeds 100 threads`);
469
+ else
470
+ unresolvedActionableThreads = (reviewThreads?.nodes ?? []).filter((thread) => !thread.isResolved && !thread.isOutdated).length;
471
+ }
472
+ catch (error) {
473
+ errors.push(`PR #${pr.number} review threads: invalid JSON (${error instanceof Error ? error.message : String(error)})`);
474
+ }
475
+ }
476
+ const branch = ghText(repoPath, [
477
+ "api",
478
+ `repos/${repository}/git/ref/heads/${encodeURIComponent(pr.headRefName)}`,
479
+ ]);
480
+ const headBranchSurvives = branch.error
481
+ ? branch.error.includes("HTTP 404")
482
+ ? false
483
+ : null
484
+ : true;
485
+ if (branch.error && headBranchSurvives === null)
486
+ errors.push(`PR #${pr.number} head branch: ${branch.error}`);
487
+ return { requiredChecks, unresolvedActionableThreads, headBranchSurvives, errors };
488
+ }
489
+ /** Completion-oriented, schema-versioned PR evidence without mutating gh account state. */
490
+ export async function auditPullRequests(repoPath, engine, options = {}) {
491
+ const errors = [];
492
+ const repositoryResult = ghText(repoPath, [
493
+ "repo",
494
+ "view",
495
+ "--json",
496
+ "nameWithOwner,url,defaultBranchRef",
497
+ ]);
498
+ if (repositoryResult.error)
499
+ errors.push(`repository: ${repositoryResult.error}`);
500
+ let repository = null;
501
+ let host = null;
502
+ let defaultBranch = null;
503
+ try {
504
+ const resolved = JSON.parse(repositoryResult.value || "{}");
505
+ repository = resolved.nameWithOwner ?? null;
506
+ host = resolved.url ? new URL(resolved.url).host : null;
507
+ defaultBranch = resolved.defaultBranchRef?.name ?? null;
508
+ }
509
+ catch (error) {
510
+ errors.push(`repository: invalid JSON (${error instanceof Error ? error.message : String(error)})`);
511
+ }
512
+ const loginResult = ghText(repoPath, [
513
+ "api",
514
+ ...(host ? ["--hostname", host] : []),
515
+ "user",
516
+ "--jq",
517
+ ".login",
518
+ ]);
519
+ if (loginResult.error)
520
+ errors.push(`authentication: ${loginResult.error}`);
521
+ const authenticatedAs = loginResult.value;
522
+ if (options.expectedLogin && authenticatedAs && options.expectedLogin !== authenticatedAs) {
523
+ errors.push(`authenticated login mismatch: expected ${options.expectedLogin}, resolved ${authenticatedAs}`);
524
+ }
525
+ if (!repository || !authenticatedAs || errors.some((error) => error.includes("login mismatch"))) {
526
+ return {
527
+ schemaVersion: 1,
528
+ status: "unavailable",
529
+ repository,
530
+ host,
531
+ authenticatedAs,
532
+ resultCount: null,
533
+ pullRequests: [],
534
+ errors,
535
+ };
536
+ }
537
+ const state = options.state ?? "open";
538
+ if (!["open", "merged", "closed", "all"].includes(state))
539
+ errors.push(`pull requests: unsupported state ${state}`);
540
+ if (options.range && !/^\d+\.\.\d+$/.test(options.range))
541
+ errors.push(`pull requests: invalid range ${options.range}; expected N..N`);
542
+ const revisions = revisionCommits(repoPath, options.base, options.head);
543
+ if (revisions.error)
544
+ errors.push(`revision range: ${revisions.error}`);
545
+ if (errors.length > 0) {
546
+ return {
547
+ schemaVersion: 1,
548
+ status: "unavailable",
549
+ repository,
550
+ host,
551
+ authenticatedAs,
552
+ resultCount: null,
553
+ pullRequests: [],
554
+ errors,
555
+ };
556
+ }
557
+ const list = ghText(repoPath, [
558
+ "pr",
559
+ "list",
560
+ "--state",
561
+ state,
562
+ "--limit",
563
+ String(options.limit ?? 50),
564
+ "--json",
565
+ PR_AUDIT_FIELDS,
566
+ ]);
567
+ if (list.error) {
568
+ errors.push(`pull requests: ${list.error}`);
569
+ return {
570
+ schemaVersion: 1,
571
+ status: "unavailable",
572
+ repository,
573
+ host,
574
+ authenticatedAs,
575
+ resultCount: null,
576
+ pullRequests: [],
577
+ errors,
578
+ };
579
+ }
580
+ let raw;
581
+ try {
582
+ raw = list.value ? JSON.parse(list.value) : [];
583
+ }
584
+ catch (error) {
585
+ errors.push(`pull requests: invalid JSON (${error instanceof Error ? error.message : String(error)})`);
586
+ raw = [];
587
+ }
588
+ const branchGlob = options.branches ?? null;
589
+ const range = options.range?.match(/^(\d+)\.\.(\d+)$/);
590
+ const minimum = range ? Number(range[1]) : null;
591
+ const maximum = range ? Number(range[2]) : null;
592
+ const selected = raw.filter((pr) => (!branchGlob || matchesBranchGlob(pr.headRefName, branchGlob)) &&
593
+ (minimum === null || maximum === null || (pr.number >= minimum && pr.number <= maximum)) &&
594
+ (revisions.commits === null ||
595
+ (pr.mergeCommit?.oid !== undefined && revisions.commits.has(pr.mergeCommit.oid))));
596
+ const pullRequests = [];
597
+ for (const pr of selected) {
598
+ const ci = computeCiStatus(pr.statusCheckRollup);
599
+ const supplement = auditSupplement(repoPath, repository, pr, defaultBranch);
600
+ errors.push(...supplement.errors);
601
+ pullRequests.push({
602
+ number: pr.number,
603
+ title: pr.title,
604
+ state: pr.state ?? state.toUpperCase(),
605
+ head: pr.headRefName,
606
+ base: pr.baseRefName ?? null,
607
+ mergeCommit: pr.mergeCommit?.oid ?? null,
608
+ url: pr.url ?? null,
609
+ isDraft: pr.isDraft ?? false,
610
+ reviewDecision: pr.reviewDecision,
611
+ approval: pr.reviewDecision === "APPROVED",
612
+ ci,
613
+ triageStatus: classifyPr(pr, ci),
614
+ observedChecks: pr.statusCheckRollup ?? [],
615
+ requiredChecks: supplement.requiredChecks,
616
+ unresolvedActionableThreads: supplement.unresolvedActionableThreads,
617
+ headBranchSurvives: supplement.headBranchSurvives,
618
+ impact: await computePrImpact(pr, engine, repoPath),
619
+ });
620
+ }
621
+ return {
622
+ schemaVersion: 1,
623
+ status: errors.length > 0 ? "partial" : "ok",
624
+ repository,
625
+ host,
626
+ authenticatedAs,
627
+ state,
628
+ resultCount: pullRequests.length,
629
+ pullRequests,
630
+ errors,
631
+ revisionRange: options.base && options.head
632
+ ? { base: options.base, head: options.head, commitCount: revisions.commits?.size ?? null }
633
+ : null,
634
+ limitations: [
635
+ "review-thread evidence is intentionally incomplete when a PR exceeds 100 threads",
636
+ ],
637
+ };
638
+ }