devflow-kit 2.4.0 → 3.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 (213) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/README.md +111 -18
  3. package/dist/agents/git.md +822 -0
  4. package/dist/cli/commands/agents.js +6 -1
  5. package/dist/cli/commands/ambient.js +160 -145
  6. package/dist/cli/commands/attribution-prompts.js +1 -1
  7. package/dist/cli/commands/capture.js +29 -55
  8. package/dist/cli/commands/compliance-prompts.js +1 -1
  9. package/dist/cli/commands/compliance.js +48 -55
  10. package/dist/cli/commands/context.js +17 -32
  11. package/dist/cli/commands/debug.js +65 -26
  12. package/dist/cli/commands/flags.js +3 -3
  13. package/dist/cli/commands/hud.js +34 -10
  14. package/dist/cli/commands/init-seed.js +61 -27
  15. package/dist/cli/commands/init.js +649 -240
  16. package/dist/cli/commands/install-report.js +200 -0
  17. package/dist/cli/commands/knowledge/index.js +2 -2
  18. package/dist/cli/commands/knowledge/toggle.js +35 -37
  19. package/dist/cli/commands/learning.js +79 -57
  20. package/dist/cli/commands/legacy-hooks.js +11 -14
  21. package/dist/cli/commands/memory.js +134 -135
  22. package/dist/cli/commands/prompt-io.js +4 -4
  23. package/dist/cli/commands/proxy.js +23 -41
  24. package/dist/cli/commands/security.js +81 -29
  25. package/dist/cli/commands/skills.js +71 -7
  26. package/dist/cli/commands/tracker-prompts.js +145 -0
  27. package/dist/cli/commands/tracker.js +277 -0
  28. package/dist/cli/commands/uninstall.js +520 -169
  29. package/dist/cli.js +2 -0
  30. package/dist/commands/bug-analysis.md +58 -14
  31. package/dist/commands/code-review.md +110 -32
  32. package/dist/commands/debug.md +55 -11
  33. package/dist/commands/dynamic-build.md +344 -73
  34. package/dist/commands/dynamic-plan.md +77 -27
  35. package/dist/commands/dynamic-profile.md +25 -11
  36. package/dist/commands/dynamic-tickets.md +76 -15
  37. package/dist/commands/explore.md +37 -7
  38. package/dist/commands/implement.md +314 -62
  39. package/dist/commands/plan.md +146 -32
  40. package/dist/commands/release.md +64 -17
  41. package/dist/commands/research.md +34 -8
  42. package/dist/commands/resolve.md +196 -68
  43. package/dist/commands/self-review.md +45 -9
  44. package/dist/core/agent-models.js +55 -12
  45. package/dist/core/assets.js +58 -2
  46. package/dist/core/compliance-compose.js +27 -27
  47. package/dist/core/evidence-policy.js +363 -0
  48. package/dist/core/feature-config.js +200 -65
  49. package/dist/core/feature-switch.js +112 -0
  50. package/dist/core/flags.js +34 -6
  51. package/dist/core/fs-atomic.js +27 -0
  52. package/dist/core/hook-log-dirs.js +104 -0
  53. package/dist/core/learning-tuning-config.js +5 -3
  54. package/dist/core/ledger-root.js +102 -0
  55. package/dist/core/manifest.js +38 -10
  56. package/dist/core/mds-variants.js +798 -0
  57. package/dist/core/migrations.js +49 -23
  58. package/dist/core/model-discovery.js +12 -1
  59. package/dist/core/plugins.js +361 -12
  60. package/dist/core/project-paths.js +1 -18
  61. package/dist/core/proxy-log.js +8 -6
  62. package/dist/core/proxy-state.js +11 -8
  63. package/dist/core/reference-sweep.js +136 -0
  64. package/dist/core/same-location.js +25 -0
  65. package/dist/core/tracker.js +494 -0
  66. package/dist/hud/components/config-counts.js +15 -4
  67. package/dist/hud/components/learning-counts.js +14 -0
  68. package/dist/hud/config.js +2 -1
  69. package/dist/hud/cost-history.js +2 -4
  70. package/dist/hud/git.js +52 -7
  71. package/dist/hud/index.js +7 -9
  72. package/dist/skills/git/references/decision-markers.md +19 -0
  73. package/dist/skills/git/references/learn-conventions.md +56 -0
  74. package/dist/skills/git/references/pr/check-ci-status.md +14 -0
  75. package/dist/skills/git/references/pr/check-merge-readiness.md +28 -0
  76. package/dist/skills/git/references/pr/ensure-pr-ready.md +24 -0
  77. package/dist/skills/git/references/pr/fetch-review-threads.md +22 -0
  78. package/dist/skills/git/references/pr/post-resolution-summary.md +40 -0
  79. package/dist/skills/git/references/pr/post-review-summary.md +42 -0
  80. package/dist/skills/git/references/pr/resolve-review-threads.md +35 -0
  81. package/dist/skills/git/references/pr/update-pr-evidence.md +14 -0
  82. package/dist/skills/git/references/pr/validate-branch.md +18 -0
  83. package/dist/skills/git/references/publication-gate.md +13 -0
  84. package/dist/skills/git/references/tracker/_mcp.md +153 -0
  85. package/dist/skills/git/references/tracker/github/associate-release.md +18 -0
  86. package/dist/skills/git/references/tracker/github/backlink-shipped-issues.md +40 -0
  87. package/dist/skills/git/references/tracker/github/create-release.md +11 -0
  88. package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +16 -0
  89. package/dist/skills/git/references/tracker/github/ensure-traceable-issue.md +69 -0
  90. package/dist/skills/git/references/tracker/github/fetch-issue.md +32 -0
  91. package/dist/skills/git/references/tracker/github/fetch-issues-batch.md +17 -0
  92. package/dist/skills/git/references/tracker/github/gather-release-evidence.md +19 -0
  93. package/dist/skills/git/references/tracker/github/manage-debt.md +101 -0
  94. package/dist/skills/git/references/tracker/github/post-wave-report.md +28 -0
  95. package/dist/skills/git/references/tracker/github/setup-task.md +26 -0
  96. package/dist/skills/git/references/tracker/jira/associate-release.md +18 -0
  97. package/dist/skills/git/references/tracker/jira/backlink-shipped-issues.md +49 -0
  98. package/dist/skills/git/references/tracker/jira/create-release.md +17 -0
  99. package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +22 -0
  100. package/dist/skills/git/references/tracker/jira/ensure-traceable-issue.md +53 -0
  101. package/dist/skills/git/references/tracker/jira/fetch-issue.md +14 -0
  102. package/dist/skills/git/references/tracker/jira/fetch-issues-batch.md +15 -0
  103. package/dist/skills/git/references/tracker/jira/gather-release-evidence.md +18 -0
  104. package/dist/skills/git/references/tracker/jira/manage-debt.md +37 -0
  105. package/dist/skills/git/references/tracker/jira/post-wave-report.md +33 -0
  106. package/dist/skills/git/references/tracker/jira/setup-task.md +31 -0
  107. package/dist/skills/git/references/tracker/linear/associate-release.md +18 -0
  108. package/dist/skills/git/references/tracker/linear/backlink-shipped-issues.md +53 -0
  109. package/dist/skills/git/references/tracker/linear/create-release.md +17 -0
  110. package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +22 -0
  111. package/dist/skills/git/references/tracker/linear/ensure-traceable-issue.md +53 -0
  112. package/dist/skills/git/references/tracker/linear/fetch-issue.md +14 -0
  113. package/dist/skills/git/references/tracker/linear/fetch-issues-batch.md +15 -0
  114. package/dist/skills/git/references/tracker/linear/gather-release-evidence.md +18 -0
  115. package/dist/skills/git/references/tracker/linear/manage-debt.md +37 -0
  116. package/dist/skills/git/references/tracker/linear/post-wave-report.md +33 -0
  117. package/dist/skills/git/references/tracker/linear/setup-task.md +32 -0
  118. package/dist/skills/git/references/trust-rule.md +7 -0
  119. package/dist/targets/claude-code/claude-paths.js +59 -57
  120. package/dist/targets/claude-code/compliance-install.js +49 -65
  121. package/dist/targets/claude-code/hooks.js +108 -3
  122. package/dist/targets/claude-code/installer.js +1187 -32
  123. package/dist/targets/claude-code/legacy.js +5 -0
  124. package/dist/targets/claude-code/post-install.js +366 -151
  125. package/dist/targets/claude-code/tracker-install.js +134 -0
  126. package/package.json +8 -6
  127. package/src/assets/agents/code.md +45 -6
  128. package/src/assets/agents/design.md +2 -1
  129. package/src/assets/agents/git.mds +825 -0
  130. package/src/assets/agents/knowledge.md +3 -3
  131. package/src/assets/agents/learning.md +11 -0
  132. package/src/assets/agents/review.md +3 -1
  133. package/src/assets/agents/synthesize.md +1 -1
  134. package/src/assets/agents/test.md +16 -5
  135. package/src/assets/agents/tracker.md +474 -0
  136. package/src/assets/agents/validate.md +7 -5
  137. package/src/assets/commands/_partials/_compliance.mds +19 -1
  138. package/src/assets/commands/_partials/_decisions.mds +15 -3
  139. package/src/assets/commands/_partials/_docs_root.mds +35 -0
  140. package/src/assets/commands/_partials/_engine.mds +13 -11
  141. package/src/assets/commands/_partials/_evidence_policy.mds +30 -0
  142. package/src/assets/commands/_partials/_factory.mds +1 -1
  143. package/src/assets/commands/_partials/_knowledge.mds +27 -9
  144. package/src/assets/commands/_partials/_plan_contract.mds +22 -7
  145. package/src/assets/commands/_partials/_preamble.mds +2 -2
  146. package/src/assets/commands/_partials/_publication.mds +8 -2
  147. package/src/assets/commands/_partials/_settings.mds +28 -0
  148. package/src/assets/commands/_partials/_ticket_template.mds +3 -2
  149. package/src/assets/commands/_partials/_tracker.mds +18 -0
  150. package/src/assets/commands/_partials/_wave.mds +16 -10
  151. package/src/assets/commands/bug-analysis.mds +31 -19
  152. package/src/assets/commands/code-review.mds +67 -41
  153. package/src/assets/commands/debug.mds +13 -7
  154. package/src/assets/commands/dynamic-build.mds +274 -66
  155. package/src/assets/commands/dynamic-plan.mds +50 -23
  156. package/src/assets/commands/dynamic-profile.mds +24 -11
  157. package/src/assets/commands/dynamic-tickets.mds +63 -16
  158. package/src/assets/commands/explore.mds +4 -5
  159. package/src/assets/commands/implement.mds +234 -67
  160. package/src/assets/commands/plan.mds +91 -33
  161. package/src/assets/commands/release.md +64 -17
  162. package/src/assets/commands/research.mds +11 -9
  163. package/src/assets/commands/resolve.mds +150 -78
  164. package/src/assets/commands/self-review.mds +24 -25
  165. package/src/assets/mds/git/_pr.mds +331 -0
  166. package/src/assets/mds/git/_references.mds +135 -0
  167. package/src/assets/mds/tracker/_common.mds +156 -0
  168. package/src/assets/mds/tracker/_github.mds +472 -0
  169. package/src/assets/mds/tracker/_jira.mds +407 -0
  170. package/src/assets/mds/tracker/_linear.mds +449 -0
  171. package/src/assets/mds/tracker/_mcp.mds +305 -0
  172. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +5 -8
  173. package/src/assets/scripts/hooks/background-memory-update +40 -19
  174. package/src/assets/scripts/hooks/capture-prompt +18 -8
  175. package/src/assets/scripts/hooks/capture-question +18 -8
  176. package/src/assets/scripts/hooks/capture-turn +27 -13
  177. package/src/assets/scripts/hooks/debug-trace +11 -6
  178. package/src/assets/scripts/hooks/ensure-devflow-init +33 -6
  179. package/src/assets/scripts/hooks/ensure-proxy +9 -8
  180. package/src/assets/scripts/hooks/ensure-root-gitignore +236 -60
  181. package/src/assets/scripts/hooks/git-marker +48 -0
  182. package/src/assets/scripts/hooks/hook-log-init +3 -1
  183. package/src/assets/scripts/hooks/json-helper.cjs +228 -5
  184. package/src/assets/scripts/hooks/lib/project-paths.cjs +1 -20
  185. package/src/assets/scripts/hooks/log-paths +80 -0
  186. package/src/assets/scripts/hooks/memory-worker +22 -13
  187. package/src/assets/scripts/hooks/pre-compact-memory +44 -15
  188. package/src/assets/scripts/hooks/preamble +1 -4
  189. package/src/assets/scripts/hooks/queue-append +146 -28
  190. package/src/assets/scripts/hooks/resolve-project-root +101 -7
  191. package/src/assets/scripts/hooks/session-start-context +534 -20
  192. package/src/assets/scripts/hooks/session-start-memory +38 -15
  193. package/src/assets/scripts/lib/project-config.cjs +633 -0
  194. package/src/assets/scripts/pr-evidence.cjs +1961 -0
  195. package/src/assets/scripts/redact-secrets.cjs +490 -62
  196. package/src/assets/scripts/release-trace.cjs +1143 -0
  197. package/src/assets/scripts/resolve-evidence-policy.cjs +1145 -0
  198. package/src/assets/scripts/resolve-settings.cjs +1054 -0
  199. package/src/assets/scripts/verify-evidence.cjs +1822 -0
  200. package/src/assets/skills/compliance/SKILL.md +4 -2
  201. package/src/assets/skills/docs-framework/SKILL.md +11 -10
  202. package/src/assets/skills/docs-framework/references/patterns.md +10 -17
  203. package/src/assets/skills/gap-analysis/SKILL.md +2 -2
  204. package/src/assets/skills/git/SKILL.md +8 -78
  205. package/src/assets/skills/git/references/github-api.md +179 -141
  206. package/src/assets/skills/git/references/patterns.md +11 -6
  207. package/src/assets/skills/review-methodology/SKILL.md +1 -1
  208. package/src/assets/skills/review-methodology/references/patterns.md +6 -61
  209. package/src/assets/skills/review-methodology/references/violations.md +14 -22
  210. package/src/assets/skills/worktree-support/SKILL.md +1 -1
  211. package/src/assets/skills/worktree-support/references/roots.md +29 -0
  212. package/src/targets/claude-code/templates/managed-settings.json +25 -9
  213. package/src/assets/agents/git.md +0 -938
@@ -0,0 +1,1822 @@
1
+ #!/usr/bin/env node
2
+ // src/assets/scripts/verify-evidence.cjs
3
+ //
4
+ // The I/O half of PR test-plan evidence. pr-evidence.cjs (a sibling under
5
+ // ~/.devflow/scripts/) owns every grammar, marker, state rule and rendering; this
6
+ // script only gathers the facts its `classify` needs — through gh and git, behind
7
+ // one injected exec — and prints a closed-vocabulary answer. A verdict here is
8
+ // never a Test or Validate claim taken on trust: a claim becomes a state only
9
+ // through SHA ancestry, the diff since the claim, and `gh run view --attempt`.
10
+ //
11
+ // Usage:
12
+ // node verify-evidence.cjs check tp|block|exceptions|wave <file>
13
+ // node verify-evidence.cjs render --plan <file>
14
+ // node verify-evidence.cjs verify --pr <n> [--publication <mode>] [--evidence <file>]
15
+ // [--state <dir>] [--block-out <file>] [--comment-out <file>] [--stale-out <file>] [--approval]
16
+ // node verify-evidence.cjs splice --pr <n> --state <dir> --block <file> --out <file>
17
+ // node verify-evidence.cjs readback --pr <n> --expect <file>
18
+ //
19
+ // stdout (D-VERIFY-STDOUT) is empty or exactly one of these, each a closed-vocabulary
20
+ // line (the render block's TP lines come from the caller's own plan file):
21
+ // check nothing — the exit code is the answer; the diagnostic is on stderr
22
+ // render the creation block: markers, `## Test Plan`, every TP unticked
23
+ // verify EVIDENCE pr:<n> head:<sha> total:<n> VERIFIED-CI:<n> ATTESTED-LOCAL:<n>
24
+ // UNVERIFIED:<n> STALE:<n> FAILED:<n> INDETERMINATE:<n> stale:<ids|none>
25
+ // exceptions:<kinds|none> approval:<yes|no|unchecked> key:<hex>
26
+ // posted:<yes|no|n/a> body:<same|changed> (one line; wrapped here)
27
+ // splice SPLICE ok|resplice (exit 0) or SPLICE conflict|malformed|oversize (exit 5)
28
+ // readback READBACK ok (exit 0) or READBACK mismatch (exit 5)
29
+ // No byte of a PR body, a comment, a review or a gh/git answer is ever printed: the
30
+ // boundary re-checks every stdout line against these shapes before writing it.
31
+ //
32
+ // Exit codes:
33
+ // 0 ok
34
+ // 1 usage error — stdout entirely empty, usage on stderr
35
+ // 2 input unusable — a file could not be read (missing, not a regular file,
36
+ // oversize, not UTF-8), the evidence file or the state directory is
37
+ // malformed, or the PR body's test-plan block cannot serve as the plan
38
+ // 3 output write failed — no stdout line is printed
39
+ // 4 remote or internal failure before any verdict — `gh pr view` failed or
40
+ // answered something unusable, or an unexpected internal error
41
+ // 5 output gate refused — `check` found the file invalid, the EVIDENCE line
42
+ // failed its grammar or its consistency checks, the compare-and-swap refused
43
+ // (conflict, malformed, oversize), or the read-back differs
44
+ //
45
+ // Design constraints (binding):
46
+ // - main() returns {code, stdout} and never calls process.exit; the single
47
+ // `require.main === module` boundary is the only stdout write and the only
48
+ // exitCode assignment
49
+ // - every subprocess is spawned with an argv array (never a shell), stdin
50
+ // ignored, a timeout and a maxBuffer; every loop and every API fan-out has a
51
+ // fixed bound, and the whole run has a wall-clock deadline
52
+ // - a revision reaches git only after SHA_RE, so none can start with `-`; `--`
53
+ // ends the revision list of the one command that takes a pathspec (diff)
54
+ // - every `gh` answer, git answer, PR body and comment is hostile: parsed into a
55
+ // typed value at the boundary, bounded, and never echoed
56
+
57
+ 'use strict';
58
+
59
+ const fs = require('fs');
60
+ const path = require('path');
61
+ const crypto = require('crypto');
62
+ const childProcess = require('child_process');
63
+
64
+ const PE = require(path.join(__dirname, 'pr-evidence.cjs'));
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Closed vocabularies and bounds
68
+ // ---------------------------------------------------------------------------
69
+
70
+ /** Exit codes by meaning (see the header). */
71
+ const EXIT_CODES = Object.freeze({
72
+ OK: 0,
73
+ USAGE: 1,
74
+ INPUT_UNUSABLE: 2,
75
+ WRITE_FAILED: 3,
76
+ REMOTE_FAILURE: 4,
77
+ OUTPUT_GATE_REFUSED: 5,
78
+ });
79
+
80
+ /**
81
+ * D-VERIFY-CAPS: the per-spawn API bounds. A call past a cap is REFUSED, never
82
+ * made, and a refused call is an unresolved fact — INDETERMINATE, never a pass.
83
+ * GH_CALLS every gh call this spawn makes, the PR read included
84
+ * RUN_VIEWS `gh run view` calls (memoised per run and attempt)
85
+ * PERMISSION_LOOKUPS logins looked up for the trust rule (pr-evidence caps it)
86
+ * TPS test-plan lines (pr-evidence's parsers cap it)
87
+ * COMMENTS / REVIEWS the newest entries of each list that are read at all
88
+ */
89
+ const CAPS = Object.freeze({
90
+ GH_CALLS: 40,
91
+ RUN_VIEWS: 10,
92
+ PERMISSION_LOOKUPS: PE.LIMITS.TRUST_LOOKUPS,
93
+ TPS: PE.LIMITS.TP_MAX,
94
+ COMMENTS: 1000,
95
+ REVIEWS: 1000,
96
+ });
97
+
98
+ /**
99
+ * D-VERIFY-DEADLINE: the whole run's wall-clock budget. The Git agent runs this
100
+ * script inside a tool call with its own timeout; past the deadline every further
101
+ * call is refused, so the script still prints a (more INDETERMINATE) line in time
102
+ * instead of being killed mid-run.
103
+ */
104
+ const DEADLINE_MS = 90000;
105
+
106
+ const GH_TIMEOUT_MS = 20000;
107
+ const GIT_FETCH_TIMEOUT_MS = 60000;
108
+ const GIT_LOCAL_TIMEOUT_MS = 20000;
109
+
110
+ /** A PR's JSON: 1,000 comments of 65,536 characters, JSON-escaped, still fits. */
111
+ const PR_VIEW_MAX_BUFFER = 64 * 1024 * 1024;
112
+ const RUN_LIST_MAX_BUFFER = 1024 * 1024;
113
+ const RUN_VIEW_MAX_BUFFER = 65536;
114
+ const LINE_MAX_BUFFER = 4096;
115
+ /** 5,000 paths of 4,096 bytes overflow this; an overflow reads as an unresolved diff. */
116
+ const DIFF_MAX_BUFFER = 16 * 1024 * 1024;
117
+ const FETCH_MAX_BUFFER = 1024 * 1024;
118
+
119
+ /** The largest file read, in bytes: INPUT_CHARS characters of 4-byte UTF-8. */
120
+ const MAX_FILE_BYTES = 4 * PE.LIMITS.INPUT_CHARS;
121
+ /** The longest command-line value accepted. */
122
+ const MAX_ARG_CHARS = 4096;
123
+ /** The longest html_url, run URL, status or conclusion string kept from gh. */
124
+ const MAX_URL_CHARS = 400;
125
+ const MAX_TOKEN_CHARS = 40;
126
+
127
+ /** The fields of the one `gh pr view` read. */
128
+ const PR_FIELDS = 'body,comments,reviews,author,headRefOid,baseRefOid,isCrossRepository,number';
129
+
130
+ /** A PR number: no leading zero, at most 10 digits (the EVIDENCE line's bound). */
131
+ const PR_RE = /^[1-9][0-9]{0,9}$/;
132
+ const SHA40_RE = /^[0-9a-f]{40}$/;
133
+
134
+ /** A review state that decides a login's position on the PR (D6). */
135
+ const DECISIVE_REVIEW_STATES = new Set(['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED']);
136
+
137
+ /** The stdout shapes of splice and readback (D-VERIFY-STDOUT). */
138
+ const SPLICE_LINE_RE = /^SPLICE (?<outcome>ok|resplice|conflict|malformed|oversize)$/;
139
+ const READBACK_LINE_RE = /^READBACK (?<outcome>ok|mismatch)$/;
140
+
141
+ const USAGE = [
142
+ 'Usage: node verify-evidence.cjs check tp|block|exceptions|wave <file>',
143
+ ' node verify-evidence.cjs render --plan <file>',
144
+ ' node verify-evidence.cjs verify --pr <n> [--publication <mode>] [--evidence <file>]',
145
+ ' [--state <dir>] [--block-out <file>] [--comment-out <file>] [--stale-out <file>] [--approval]',
146
+ ' node verify-evidence.cjs splice --pr <n> --state <dir> --block <file> --out <file>',
147
+ ' node verify-evidence.cjs readback --pr <n> --expect <file>',
148
+ ].join('\n');
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // Types
152
+ // ---------------------------------------------------------------------------
153
+
154
+ /**
155
+ * @typedef {{ status: number | null, stdout?: Buffer | string, stderr?: Buffer | string, error?: { code?: string } }} ExecResult
156
+ * The spawnSync subset this script reads.
157
+ * @typedef {(file: string, args: string[], opts: object) => ExecResult} ExecFn
158
+ * Called exactly like child_process.spawnSync(file, args, opts).
159
+ *
160
+ * @typedef {{ ok: boolean, status: number | null, errorCode: string | null, stdout: Buffer,
161
+ * stderr: string, refused: null | 'cap' | 'throttled' | 'deadline' }} CallResult
162
+ * `refused` is set when the call was never made.
163
+ *
164
+ * @typedef {{ ghCalls: number, runViews: number, throttled: boolean }} Budget
165
+ *
166
+ * @typedef {{ exec: ExecFn, env: NodeJS.ProcessEnv, cwd: string, now: () => number,
167
+ * deadline: number, budget: Budget, notes: Set<string>, stderr: (text: string) => void }} Io
168
+ * The imperative shell's one context: every call goes through it, so every
169
+ * bound is counted in one place. `notes` holds closed tokens for the stderr
170
+ * summary, never input bytes.
171
+ *
172
+ * @typedef {{ login: string, association: string, body: string, viewerDidAuthor: boolean }} PrComment
173
+ * @typedef {{ login: string, association: string, state: string }} PrReview
174
+ * @typedef {{ number: number, head: string, base: string, body: string, author: string,
175
+ * isCrossRepository: boolean | undefined, comments: readonly PrComment[],
176
+ * reviews: readonly PrReview[] }} PrFacts
177
+ * The one `gh pr view` answer, parsed. Lists are chronological, newest last.
178
+ *
179
+ * @typedef {{ exec?: ExecFn, cwd?: string, now?: () => number, stderr?: (text: string) => void,
180
+ * formatLine?: (fields: object) => unknown }} MainDeps
181
+ * Injected by tests only; `formatLine` reaches the output gate.
182
+ *
183
+ * @typedef {{ code: number, stdout: string }} Outcome
184
+ */
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // Argument parsing
188
+ // ---------------------------------------------------------------------------
189
+
190
+ /**
191
+ * @typedef {{ kind: 'usage' }
192
+ * | { kind: 'check', what: 'tp' | 'block' | 'exceptions' | 'wave', file: string }
193
+ * | { kind: 'render', plan: string }
194
+ * | { kind: 'verify', pr: number, publication: string, evidence: string | null, state: string | null,
195
+ * blockOut: string | null, commentOut: string | null, staleOut: string | null, approval: boolean }
196
+ * | { kind: 'splice', pr: number, state: string, block: string, out: string }
197
+ * | { kind: 'readback', pr: number, expect: string }} ParsedArgs
198
+ */
199
+
200
+ /** @type {ParsedArgs} */
201
+ const USAGE_ARGS = Object.freeze({ kind: 'usage' });
202
+
203
+ /**
204
+ * Parse `--flag value` pairs and bare switches. Unknown, duplicated or
205
+ * value-less flags, `--flag=value`, positionals and oversize values are all
206
+ * usage errors — never ignored.
207
+ *
208
+ * @param {readonly string[]} tail
209
+ * @param {Readonly<Record<string, 'value' | 'switch'>>} spec
210
+ * @returns {Map<string, string | true> | null}
211
+ */
212
+ function parseFlags(tail, spec) {
213
+ /** @type {Map<string, string | true>} */
214
+ const out = new Map();
215
+ for (let i = 0; i < tail.length; i++) {
216
+ const flag = tail[i];
217
+ if (typeof flag !== 'string' || !Object.prototype.hasOwnProperty.call(spec, flag) || out.has(flag)) return null;
218
+ if (spec[flag] === 'switch') {
219
+ out.set(flag, true);
220
+ continue;
221
+ }
222
+ const value = tail[i + 1];
223
+ if (typeof value !== 'string' || value === '' || value.length > MAX_ARG_CHARS || value.includes('\0')) return null;
224
+ if (Object.prototype.hasOwnProperty.call(spec, value)) return null;
225
+ out.set(flag, value);
226
+ i++;
227
+ }
228
+ return out;
229
+ }
230
+
231
+ /**
232
+ * @param {Map<string, string | true>} flags
233
+ * @param {string} name
234
+ * @returns {string | null}
235
+ */
236
+ function flagValue(flags, name) {
237
+ const v = flags.get(name);
238
+ return typeof v === 'string' ? v : null;
239
+ }
240
+
241
+ /**
242
+ * @param {string | null} value
243
+ * @returns {number | null}
244
+ */
245
+ function parsePr(value) {
246
+ return value !== null && PR_RE.test(value) ? Number(value) : null;
247
+ }
248
+
249
+ /**
250
+ * @param {readonly string[]} argv process.argv
251
+ * @returns {ParsedArgs}
252
+ */
253
+ function parseArgs(argv) {
254
+ const rest = Array.isArray(argv) ? argv.slice(2) : [];
255
+ const sub = rest[0];
256
+ const tail = rest.slice(1);
257
+ if (sub === 'check') {
258
+ if (tail.length !== 2 || !['tp', 'block', 'exceptions', 'wave'].includes(tail[0])) return USAGE_ARGS;
259
+ const file = tail[1];
260
+ if (typeof file !== 'string' || file === '' || file.startsWith('-') || file.length > MAX_ARG_CHARS) return USAGE_ARGS;
261
+ return { kind: 'check', what: /** @type {'tp' | 'block' | 'exceptions' | 'wave'} */ (tail[0]), file };
262
+ }
263
+ if (sub === 'render') {
264
+ const f = parseFlags(tail, { '--plan': 'value' });
265
+ const plan = f === null ? null : flagValue(f, '--plan');
266
+ return plan === null ? USAGE_ARGS : { kind: 'render', plan };
267
+ }
268
+ if (sub === 'verify') {
269
+ const f = parseFlags(tail, {
270
+ '--pr': 'value', '--publication': 'value', '--evidence': 'value', '--state': 'value',
271
+ '--block-out': 'value', '--comment-out': 'value', '--stale-out': 'value', '--approval': 'switch',
272
+ });
273
+ const pr = f === null ? null : parsePr(flagValue(f, '--pr'));
274
+ if (f === null || pr === null) return USAGE_ARGS;
275
+ return {
276
+ kind: 'verify',
277
+ pr,
278
+ publication: flagValue(f, '--publication') || '',
279
+ evidence: flagValue(f, '--evidence'),
280
+ state: flagValue(f, '--state'),
281
+ blockOut: flagValue(f, '--block-out'),
282
+ commentOut: flagValue(f, '--comment-out'),
283
+ staleOut: flagValue(f, '--stale-out'),
284
+ approval: f.get('--approval') === true,
285
+ };
286
+ }
287
+ if (sub === 'splice') {
288
+ const f = parseFlags(tail, { '--pr': 'value', '--state': 'value', '--block': 'value', '--out': 'value' });
289
+ const pr = f === null ? null : parsePr(flagValue(f, '--pr'));
290
+ if (f === null || pr === null) return USAGE_ARGS;
291
+ const state = flagValue(f, '--state');
292
+ const block = flagValue(f, '--block');
293
+ const out = flagValue(f, '--out');
294
+ return state === null || block === null || out === null ? USAGE_ARGS : { kind: 'splice', pr, state, block, out };
295
+ }
296
+ if (sub === 'readback') {
297
+ const f = parseFlags(tail, { '--pr': 'value', '--expect': 'value' });
298
+ const pr = f === null ? null : parsePr(flagValue(f, '--pr'));
299
+ const expect = f === null ? null : flagValue(f, '--expect');
300
+ return pr === null || expect === null ? USAGE_ARGS : { kind: 'readback', pr, expect };
301
+ }
302
+ return USAGE_ARGS;
303
+ }
304
+
305
+ // ---------------------------------------------------------------------------
306
+ // Bounded file I/O
307
+ // ---------------------------------------------------------------------------
308
+
309
+ /**
310
+ * Read a regular file of at most MAX_FILE_BYTES as strict UTF-8, or null. The
311
+ * open adds O_NONBLOCK, so a FIFO put in a file's place cannot block, and the
312
+ * fstat re-checks the opened object. Malformed UTF-8 is refused, not replaced.
313
+ *
314
+ * @param {string} filePath
315
+ * @returns {string | null}
316
+ */
317
+ function readTextFile(filePath) {
318
+ let fd;
319
+ try {
320
+ fd = fs.openSync(filePath, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0));
321
+ } catch (_) {
322
+ return null;
323
+ }
324
+ try {
325
+ const st = fs.fstatSync(fd);
326
+ if (!st.isFile() || st.size > MAX_FILE_BYTES) return null;
327
+ const buf = Buffer.alloc(st.size + 1);
328
+ let total = 0;
329
+ for (let i = 0; i < buf.length; i++) {
330
+ const n = fs.readSync(fd, buf, total, buf.length - total, null);
331
+ if (n === 0) break;
332
+ total += n;
333
+ if (total === buf.length) break;
334
+ }
335
+ if (total > st.size) return null;
336
+ const bytes = buf.subarray(0, total);
337
+ const text = bytes.toString('utf8');
338
+ if (!Buffer.from(text, 'utf8').equals(bytes) || text.length > PE.LIMITS.INPUT_CHARS) return null;
339
+ return text;
340
+ } catch (_) {
341
+ return null;
342
+ } finally {
343
+ try { fs.closeSync(fd); } catch (_) { /* the read already decided */ }
344
+ }
345
+ }
346
+
347
+ /**
348
+ * Write `text` to `filePath`, refusing anything that already exists and is not a
349
+ * regular file (a directory, a FIFO, a symlink). Returns whether it was written.
350
+ *
351
+ * @param {string} filePath
352
+ * @param {string} text
353
+ * @returns {boolean}
354
+ */
355
+ function writeTextFile(filePath, text) {
356
+ try {
357
+ const st = fs.lstatSync(filePath, { throwIfNoEntry: false });
358
+ if (st !== undefined && !st.isFile()) return false;
359
+ fs.writeFileSync(filePath, text, { encoding: 'utf8', mode: 0o600 });
360
+ return true;
361
+ } catch (_) {
362
+ return false;
363
+ }
364
+ }
365
+
366
+ /**
367
+ * @param {string} dir
368
+ * @returns {boolean}
369
+ */
370
+ function isRealDirectory(dir) {
371
+ try {
372
+ const st = fs.lstatSync(dir, { throwIfNoEntry: false });
373
+ return st !== undefined && st.isDirectory();
374
+ } catch (_) {
375
+ return false;
376
+ }
377
+ }
378
+
379
+ /** @param {string} text @returns {string} */
380
+ function sha256(text) {
381
+ return crypto.createHash('sha256').update(text, 'utf8').digest('hex');
382
+ }
383
+
384
+ /**
385
+ * The first line of `text`, without its line ending.
386
+ *
387
+ * @param {string} text
388
+ * @returns {string}
389
+ */
390
+ function firstLine(text) {
391
+ const nl = text.indexOf('\n');
392
+ const line = nl === -1 ? text : text.slice(0, nl);
393
+ return line.endsWith('\r') ? line.slice(0, -1) : line;
394
+ }
395
+
396
+ /**
397
+ * Strip one trailing line break (`\n` or `\r\n`).
398
+ *
399
+ * @param {string} text
400
+ * @returns {string}
401
+ */
402
+ function stripOneNewline(text) {
403
+ if (text.endsWith('\r\n')) return text.slice(0, -2);
404
+ if (text.endsWith('\n')) return text.slice(0, -1);
405
+ return text;
406
+ }
407
+
408
+ // ---------------------------------------------------------------------------
409
+ // Subprocess calls (D-VERIFY-IO)
410
+ // ---------------------------------------------------------------------------
411
+
412
+ /** @param {unknown} value @returns {Buffer} */
413
+ function asBuffer(value) {
414
+ if (Buffer.isBuffer(value)) return value;
415
+ if (typeof value === 'string') return Buffer.from(value, 'utf8');
416
+ return Buffer.alloc(0);
417
+ }
418
+
419
+ /**
420
+ * @param {'cap' | 'throttled' | 'deadline'} reason
421
+ * @returns {CallResult}
422
+ */
423
+ function refusedCall(reason) {
424
+ return { ok: false, status: null, errorCode: null, stdout: Buffer.alloc(0), stderr: '', refused: reason };
425
+ }
426
+
427
+ /**
428
+ * One bounded subprocess call, normalized. The argv array is a fresh copy.
429
+ *
430
+ * @param {Io} io
431
+ * @param {string} file
432
+ * @param {readonly string[]} args
433
+ * @param {number} timeout
434
+ * @param {number} maxBuffer
435
+ * @returns {CallResult}
436
+ */
437
+ function runCall(io, file, args, timeout, maxBuffer) {
438
+ const res = io.exec(file, [...args], {
439
+ cwd: io.cwd,
440
+ env: io.env,
441
+ stdio: ['ignore', 'pipe', 'pipe'],
442
+ timeout,
443
+ maxBuffer,
444
+ windowsHide: true,
445
+ shell: false,
446
+ });
447
+ const errorCode = res && res.error ? String(res.error.code || 'EUNKNOWN') : null;
448
+ const status = res && typeof res.status === 'number' ? res.status : null;
449
+ return {
450
+ ok: errorCode === null && status === 0,
451
+ status,
452
+ errorCode,
453
+ stdout: asBuffer(res && res.stdout),
454
+ stderr: asBuffer(res && res.stderr).toString('utf8').slice(0, 4096),
455
+ refused: null,
456
+ };
457
+ }
458
+
459
+ /**
460
+ * Whether a call ran to completion and exited on its own. Only an answered exit
461
+ * is a real "no"; a call that never started, timed out, overflowed or was refused
462
+ * is NOT KNOWING, and never reads as the permissive answer (avoids PF-075).
463
+ *
464
+ * @param {CallResult} r
465
+ * @returns {boolean}
466
+ */
467
+ function answered(r) {
468
+ return r.refused === null && r.errorCode === null && r.status !== null;
469
+ }
470
+
471
+ /**
472
+ * D-VERIFY-THROTTLE: a primary or secondary rate limit — HTTP 429, or HTTP 403
473
+ * whose message names a rate limit. A plain 403 (no push access to read a
474
+ * collaborator's permission) is an ordinary refusal, not a throttle.
475
+ *
476
+ * @param {CallResult} r
477
+ * @returns {boolean}
478
+ */
479
+ function isThrottle(r) {
480
+ if (r.refused !== null || r.status === 0) return false;
481
+ return /HTTP 429\b/.test(r.stderr) || (/HTTP 403\b/.test(r.stderr) && /rate limit/i.test(r.stderr));
482
+ }
483
+
484
+ /**
485
+ * The time left before the deadline, or 0.
486
+ *
487
+ * @param {Io} io
488
+ * @returns {number}
489
+ */
490
+ function remaining(io) {
491
+ return Math.max(0, io.deadline - io.now());
492
+ }
493
+
494
+ /**
495
+ * D-VERIFY-CAPS / D-VERIFY-THROTTLE: every gh call. After a throttle every later
496
+ * gh call is refused (the rate limit stops all calls); past GH_CALLS or the
497
+ * deadline, likewise. A refusal is recorded as a closed note.
498
+ *
499
+ * @param {Io} io
500
+ * @param {readonly string[]} args
501
+ * @param {number} maxBuffer
502
+ * @returns {CallResult}
503
+ */
504
+ function gh(io, args, maxBuffer) {
505
+ if (io.budget.throttled) { io.notes.add('throttled'); return refusedCall('throttled'); }
506
+ if (io.budget.ghCalls >= CAPS.GH_CALLS) { io.notes.add('gh-call-cap'); return refusedCall('cap'); }
507
+ const left = remaining(io);
508
+ if (left === 0) { io.notes.add('deadline'); return refusedCall('deadline'); }
509
+ io.budget.ghCalls++;
510
+ const r = runCall(io, 'gh', args, Math.min(GH_TIMEOUT_MS, left), maxBuffer);
511
+ if (isThrottle(r)) {
512
+ io.budget.throttled = true;
513
+ io.notes.add('throttled');
514
+ }
515
+ return r;
516
+ }
517
+
518
+ /**
519
+ * Every git call: `-c core.fsmonitor=false` first, so no repository-configured
520
+ * hook runs (D-NO-FSMONITOR), and bounded by the deadline.
521
+ *
522
+ * @param {Io} io
523
+ * @param {readonly string[]} args
524
+ * @param {number} timeout
525
+ * @param {number} maxBuffer
526
+ * @returns {CallResult}
527
+ */
528
+ function git(io, args, timeout, maxBuffer) {
529
+ const left = remaining(io);
530
+ if (left === 0) { io.notes.add('deadline'); return refusedCall('deadline'); }
531
+ return runCall(io, 'git', ['-c', 'core.fsmonitor=false', ...args], Math.min(timeout, left), maxBuffer);
532
+ }
533
+
534
+ /**
535
+ * Assert a revision before it reaches git's argv: exactly 40 lowercase hex, so it
536
+ * can never be read as an option. Every caller has already parsed it; this is the
537
+ * precondition held at the sink.
538
+ *
539
+ * @param {string} sha
540
+ * @returns {string}
541
+ */
542
+ function rev(sha) {
543
+ if (!PE.SHA_RE.test(sha) || !SHA40_RE.test(sha)) throw new Error('verify-evidence: unchecked revision');
544
+ return sha;
545
+ }
546
+
547
+ /**
548
+ * Assert a login before it reaches gh's argv (an API path segment): LOGIN_RE, so
549
+ * no `/`, `..`, `?` or option can ride on PR data. permissionLookups only ever
550
+ * names such logins; this is that precondition held at the sink.
551
+ *
552
+ * @param {string} login
553
+ * @returns {string}
554
+ */
555
+ function loginArg(login) {
556
+ if (typeof login !== 'string' || !PE.LOGIN_RE.test(login)) throw new Error('verify-evidence: unchecked login');
557
+ return login;
558
+ }
559
+
560
+ // ---------------------------------------------------------------------------
561
+ // The PR (one gh pr view)
562
+ // ---------------------------------------------------------------------------
563
+
564
+ /**
565
+ * @param {unknown} v
566
+ * @returns {Record<string, unknown>}
567
+ */
568
+ function obj(v) {
569
+ return typeof v === 'object' && v !== null && !Array.isArray(v) ? /** @type {Record<string, unknown>} */ (v) : {};
570
+ }
571
+
572
+ /**
573
+ * @param {unknown} author
574
+ * @returns {string}
575
+ */
576
+ function loginOf(author) {
577
+ const login = obj(author).login;
578
+ return typeof login === 'string' && login.length <= 100 ? login : '';
579
+ }
580
+
581
+ /**
582
+ * Parse the `gh pr view --json` answer into PrFacts, or null when it is not the
583
+ * PR that was asked for or a field has the wrong shape. Malformed list entries
584
+ * are kept with empty fields (they then match nothing), so one bad comment cannot
585
+ * hide the rest; each list keeps only its newest CAPS entries.
586
+ *
587
+ * @param {unknown} json
588
+ * @param {number} pr
589
+ * @returns {PrFacts | null}
590
+ */
591
+ function parsePrJson(json, pr) {
592
+ const o = obj(json);
593
+ if (o.number !== pr) return null;
594
+ if (typeof o.headRefOid !== 'string' || !SHA40_RE.test(o.headRefOid)) return null;
595
+ if (typeof o.baseRefOid !== 'string' || !SHA40_RE.test(o.baseRefOid)) return null;
596
+ if (typeof o.body !== 'string' || o.body.length > PE.LIMITS.INPUT_CHARS) return null;
597
+ const author = loginOf(o.author);
598
+ if (author === '' || !Array.isArray(o.comments) || !Array.isArray(o.reviews)) return null;
599
+ const comments = o.comments.slice(-CAPS.COMMENTS).map(c => {
600
+ const x = obj(c);
601
+ return Object.freeze({
602
+ login: loginOf(x.author),
603
+ association: typeof x.authorAssociation === 'string' ? x.authorAssociation : '',
604
+ body: typeof x.body === 'string' ? x.body : '',
605
+ viewerDidAuthor: x.viewerDidAuthor === true,
606
+ });
607
+ });
608
+ const reviews = o.reviews.slice(-CAPS.REVIEWS).map(r => {
609
+ const x = obj(r);
610
+ return Object.freeze({
611
+ login: loginOf(x.author),
612
+ association: typeof x.authorAssociation === 'string' ? x.authorAssociation : '',
613
+ state: typeof x.state === 'string' ? x.state : '',
614
+ });
615
+ });
616
+ return Object.freeze({
617
+ number: pr,
618
+ head: o.headRefOid,
619
+ base: o.baseRefOid,
620
+ body: o.body,
621
+ author,
622
+ isCrossRepository: typeof o.isCrossRepository === 'boolean' ? o.isCrossRepository : undefined,
623
+ comments: Object.freeze(comments),
624
+ reviews: Object.freeze(reviews),
625
+ });
626
+ }
627
+
628
+ /**
629
+ * @param {Io} io
630
+ * @param {number} pr
631
+ * @param {string} fields
632
+ * @returns {unknown | null} the parsed JSON, or null when the call or the parse failed
633
+ */
634
+ function prView(io, pr, fields) {
635
+ const r = gh(io, ['pr', 'view', String(pr), '--json', fields], PR_VIEW_MAX_BUFFER);
636
+ if (!r.ok) return null;
637
+ try {
638
+ return JSON.parse(r.stdout.toString('utf8'));
639
+ } catch (_) {
640
+ return null;
641
+ }
642
+ }
643
+
644
+ /**
645
+ * The PR body alone (splice and readback), or null.
646
+ *
647
+ * @param {Io} io
648
+ * @param {number} pr
649
+ * @returns {string | null}
650
+ */
651
+ function readBody(io, pr) {
652
+ const body = obj(prView(io, pr, 'body')).body;
653
+ return typeof body === 'string' && body.length <= PE.LIMITS.INPUT_CHARS ? body : null;
654
+ }
655
+
656
+ // ---------------------------------------------------------------------------
657
+ // The trust rule's inputs (pr-evidence `trust` / `permissionLookups` decide)
658
+ // ---------------------------------------------------------------------------
659
+
660
+ /**
661
+ * D-VERIFY-VIEWER: VIEWER_LOGIN is the login of the comments GitHub marks
662
+ * `viewerDidAuthor` — asserted server-side, so no extra `gh api user` call. When
663
+ * the viewer has not commented on this PR the viewer arm matches no comment at all
664
+ * (true) and a viewer's review falls back to the association arm, which only
665
+ * narrows trust. Two different logins so marked is impossible, and reads as no
666
+ * viewer.
667
+ *
668
+ * @param {PrFacts} pr
669
+ * @returns {string}
670
+ */
671
+ function viewerLogin(pr) {
672
+ const logins = new Set(pr.comments.filter(c => c.viewerDidAuthor && c.login !== '').map(c => c.login));
673
+ return logins.size === 1 ? [...logins][0] : '';
674
+ }
675
+
676
+ /**
677
+ * The comments whose first line is an evidence marker, newest first.
678
+ *
679
+ * @param {PrFacts} pr
680
+ * @returns {PrComment[]}
681
+ */
682
+ function markerComments(pr) {
683
+ return pr.comments.filter(c => PE.MARKERS.EVIDENCE_RE.test(firstLine(c.body))).reverse();
684
+ }
685
+
686
+ /**
687
+ * D-VERIFY-APPROVAL (D6): each login's LATEST decisive review — APPROVED,
688
+ * CHANGES_REQUESTED or DISMISSED — in chronological order.
689
+ *
690
+ * @param {PrFacts} pr
691
+ * @returns {Map<string, PrReview>}
692
+ */
693
+ function latestDecisiveReviews(pr) {
694
+ /** @type {Map<string, PrReview>} */
695
+ const latest = new Map();
696
+ for (const r of pr.reviews) {
697
+ if (r.login !== '' && DECISIVE_REVIEW_STATES.has(r.state)) {
698
+ latest.delete(r.login);
699
+ latest.set(r.login, r);
700
+ }
701
+ }
702
+ return latest;
703
+ }
704
+
705
+ /**
706
+ * The approvers whose trust decides `approval`: logins other than the PR author
707
+ * whose latest decisive review is APPROVED, most recent first.
708
+ *
709
+ * @param {PrFacts} pr
710
+ * @returns {PrReview[]}
711
+ */
712
+ function approvalCandidates(pr) {
713
+ const author = pr.author.toLowerCase();
714
+ return [...latestDecisiveReviews(pr).values()]
715
+ .filter(r => r.state === 'APPROVED' && r.login.toLowerCase() !== author)
716
+ .reverse();
717
+ }
718
+
719
+ /**
720
+ * @typedef {{ ctx: { viewer: string, prAuthor: string, isCrossRepository: boolean | undefined,
721
+ * permissions: Map<string, string | null> }, refused: Set<string> }} TrustState
722
+ * `refused` holds logins that permissionLookups named but whose lookup gave no
723
+ * ANSWER (a refusal — throttle, cap, deadline — or a timeout, a spawn error, a
724
+ * 5xx, a network failure): trust() still reads them untrusted, as the rule says of
725
+ * an error, but whether such a login's comment is the NEWEST trusted record is
726
+ * unknown, so findTrustedRecord never falls back past it to an older record.
727
+ */
728
+
729
+ /**
730
+ * Whether a permission lookup that did not print a permission was nevertheless an
731
+ * ANSWER: GitHub said 404 (not a collaborator) or a plain 403 (the viewer may not
732
+ * read it). Everything else that failed is not knowing.
733
+ *
734
+ * @param {CallResult} r
735
+ * @returns {boolean}
736
+ */
737
+ function lookupDenied(r) {
738
+ return answered(r) && /HTTP 40[34]\b/.test(r.stderr) && !isThrottle(r);
739
+ }
740
+
741
+ /**
742
+ * Look up the permissions the trust rule needs — ONCE per spawn, for exactly the
743
+ * logins pr-evidence `permissionLookups` names (≤ PERMISSION_LOOKUPS). Only the
744
+ * authors whose trust can matter are offered: evidence-marker comment authors,
745
+ * newest first, then (with --approval) the approval candidates. A printed
746
+ * permission is stored as printed; a 404 or a plain 403 stores null — untrusted,
747
+ * per the rule; any other failure leaves the login `refused` (see TrustState).
748
+ *
749
+ * @param {Io} io
750
+ * @param {PrFacts} pr
751
+ * @param {boolean} approval
752
+ * @returns {TrustState}
753
+ */
754
+ function resolveTrust(io, pr, approval) {
755
+ /** @type {Map<string, string | null>} */
756
+ const permissions = new Map();
757
+ const ctx = { viewer: viewerLogin(pr), prAuthor: pr.author, isCrossRepository: pr.isCrossRepository, permissions };
758
+ const actors = [
759
+ ...markerComments(pr).map(c => ({ login: c.login, association: c.association })),
760
+ ...(approval ? approvalCandidates(pr).map(r => ({ login: r.login, association: r.association })) : []),
761
+ ];
762
+ /** @type {Set<string>} */
763
+ const refused = new Set();
764
+ for (const login of PE.permissionLookups(actors, ctx)) {
765
+ const r = gh(io, ['api', 'repos/{owner}/{repo}/collaborators/' + loginArg(login) + '/permission', '--jq', '.permission'],
766
+ LINE_MAX_BUFFER);
767
+ if (r.refused !== null || isThrottle(r) || !(r.ok || lookupDenied(r))) {
768
+ refused.add(login);
769
+ continue;
770
+ }
771
+ const value = r.ok ? r.stdout.toString('utf8').replace(/\n$/, '') : '';
772
+ permissions.set(login, /^[a-z]{1,20}$/.test(value) ? value : null);
773
+ }
774
+ return { ctx, refused };
775
+ }
776
+
777
+ /**
778
+ * @typedef {{ kind: 'none' } | { kind: 'unknown' }
779
+ * | { kind: 'record', head: string, records: readonly any[], exceptions: readonly any[] }} TrustedRecord
780
+ */
781
+
782
+ /**
783
+ * D-VERIFY-RECORD: the trusted record is the NEWEST evidence-marker comment whose
784
+ * author passes `trust()`. When that newest trusted comment does not parse there
785
+ * is no record — an older one is never used in its place, because it may carry a
786
+ * pass the newer one withdrew. When a newer marker comment's author could not be
787
+ * looked up (refused), whether IT is the record is unknown, and so is the record.
788
+ *
789
+ * @param {PrFacts} pr
790
+ * @param {TrustState} t
791
+ * @returns {TrustedRecord}
792
+ */
793
+ function findTrustedRecord(pr, t) {
794
+ for (const c of markerComments(pr)) {
795
+ const actor = { login: c.login, association: c.association };
796
+ const isViewer = t.ctx.viewer !== '' && c.login === t.ctx.viewer;
797
+ if (!isViewer && t.refused.has(c.login)) return { kind: 'unknown' };
798
+ if (!PE.trust(actor, t.ctx)) continue;
799
+ const parsed = PE.parseEvidenceComment(c.body);
800
+ return parsed.ok
801
+ ? { kind: 'record', head: parsed.value.head, records: parsed.value.records, exceptions: parsed.value.exceptions }
802
+ : { kind: 'none' };
803
+ }
804
+ return { kind: 'none' };
805
+ }
806
+
807
+ /**
808
+ * D-VERIFY-APPROVAL (D6): `yes` when a trusted login other than the PR author has
809
+ * APPROVED as its latest decisive review; otherwise `no` — including when a
810
+ * lookup was refused, which can only withhold a yes.
811
+ *
812
+ * @param {PrFacts} pr
813
+ * @param {TrustState} t
814
+ * @returns {'yes' | 'no'}
815
+ */
816
+ function approvalOf(pr, t) {
817
+ for (const r of approvalCandidates(pr)) {
818
+ if (PE.trust({ login: r.login, association: r.association }, t.ctx)) return 'yes';
819
+ }
820
+ return 'no';
821
+ }
822
+
823
+ // ---------------------------------------------------------------------------
824
+ // git facts — the head, ancestry, the diff
825
+ // ---------------------------------------------------------------------------
826
+
827
+ /**
828
+ * @typedef {{ head: string, base: string, headResolved: boolean, ancestryUsable: boolean,
829
+ * ancestry: Map<string, boolean | null>, diffs: Map<string, readonly string[] | null> }} Repo
830
+ */
831
+
832
+ /**
833
+ * @param {Io} io
834
+ * @param {string} sha
835
+ * @returns {boolean}
836
+ */
837
+ function commitResolves(io, sha) {
838
+ const r = git(io, ['rev-parse', '--verify', '--quiet', rev(sha) + '^{commit}'], GIT_LOCAL_TIMEOUT_MS, LINE_MAX_BUFFER);
839
+ return r.ok && r.stdout.toString('utf8').replace(/\n$/, '') === sha;
840
+ }
841
+
842
+ /**
843
+ * @param {Io} io
844
+ * @param {string} refspec `refs/pull/<n>/head`, or a 40-hex commit
845
+ * @returns {void}
846
+ */
847
+ function fetchOrigin(io, refspec) {
848
+ git(io, ['fetch', '--no-tags', '--no-write-fetch-head', '--no-recurse-submodules', 'origin', refspec],
849
+ GIT_FETCH_TIMEOUT_MS, FETCH_MAX_BUFFER);
850
+ }
851
+
852
+ /**
853
+ * Make the PR's commits local and say what can be decided from them.
854
+ *
855
+ * The fetch of refs/pull/<n>/head only adds objects (no ref is written); what it
856
+ * decides is whether headRefOid then RESOLVES — objects are content-addressed, so
857
+ * a head that was already local serves as well as a fetched one.
858
+ *
859
+ * D-VERIFY-BASE-FETCH: the base commit must be local too (see inPr). When it is
860
+ * not — the base branch moved since this clone last fetched — it is fetched by
861
+ * its SHA, once. Unresolvable, it leaves ancestry undecidable (INDETERMINATE).
862
+ *
863
+ * A shallow repository cannot answer ancestry — a truncated history reads "not an
864
+ * ancestor" where the truth is unknown — so ancestry is undecidable there too.
865
+ *
866
+ * @param {Io} io
867
+ * @param {PrFacts} pr
868
+ * @returns {Repo}
869
+ */
870
+ function prepareRepo(io, pr) {
871
+ fetchOrigin(io, 'refs/pull/' + pr.number + '/head');
872
+ const headResolved = commitResolves(io, pr.head);
873
+ let ancestryUsable = false;
874
+ if (headResolved) {
875
+ const shallow = git(io, ['rev-parse', '--is-shallow-repository'], GIT_LOCAL_TIMEOUT_MS, LINE_MAX_BUFFER);
876
+ const complete = shallow.ok && shallow.stdout.toString('utf8') === 'false\n';
877
+ let baseResolved = complete && commitResolves(io, pr.base);
878
+ if (complete && !baseResolved) {
879
+ fetchOrigin(io, rev(pr.base));
880
+ baseResolved = commitResolves(io, pr.base);
881
+ }
882
+ ancestryUsable = complete && baseResolved;
883
+ }
884
+ if (!headResolved) io.notes.add('head-unresolved');
885
+ else if (!ancestryUsable) io.notes.add('ancestry-unavailable');
886
+ return { head: pr.head, base: pr.base, headResolved, ancestryUsable, ancestry: new Map(), diffs: new Map() };
887
+ }
888
+
889
+ /**
890
+ * `git merge-base --is-ancestor a b`: true (exit 0), false (exit 1), or null for
891
+ * anything else — an unknown object, a timeout, a refusal.
892
+ *
893
+ * @param {Io} io
894
+ * @param {string} a
895
+ * @param {string} b
896
+ * @returns {boolean | null}
897
+ */
898
+ function isAncestor(io, a, b) {
899
+ const r = git(io, ['merge-base', '--is-ancestor', rev(a), rev(b)], GIT_LOCAL_TIMEOUT_MS, LINE_MAX_BUFFER);
900
+ if (!answered(r)) return null;
901
+ if (r.status === 0) return true;
902
+ if (r.status === 1) return false;
903
+ return null;
904
+ }
905
+
906
+ /**
907
+ * D-VERIFY-IN-PR: whether a claim SHA is in the PR — an ancestor-or-equal of the
908
+ * head, and NOT an ancestor-or-equal of merge-base(head, base). Computed as "not
909
+ * an ancestor-or-equal of the BASE", which is the same set: a commit reachable
910
+ * from both head and base is a common ancestor, and every common ancestor is an
911
+ * ancestor-or-equal of some merge base (and each merge base of the base). This
912
+ * form needs no merge-base call and stays exact when there are several merge
913
+ * bases. Memoised per SHA.
914
+ *
915
+ * @param {Io} io
916
+ * @param {Repo} repo
917
+ * @param {string} sha
918
+ * @returns {boolean | null}
919
+ */
920
+ function inPr(io, repo, sha) {
921
+ if (!repo.ancestryUsable) return null;
922
+ const memo = repo.ancestry.get(sha);
923
+ if (memo !== undefined) return memo;
924
+ let answer = null;
925
+ const inHead = isAncestor(io, sha, repo.head);
926
+ if (inHead === false) answer = false;
927
+ else if (inHead === true) {
928
+ const inBase = isAncestor(io, sha, repo.base);
929
+ answer = inBase === null ? null : !inBase;
930
+ }
931
+ repo.ancestry.set(sha, answer);
932
+ return answer;
933
+ }
934
+
935
+ /**
936
+ * The paths changed between the claim and the head:
937
+ * `git diff --no-renames --name-only <claim> <head> --`, NUL-separated (`-z`, so
938
+ * no path is C-quoted and missed by a glob), without external diff drivers and
939
+ * with `--no-relative` (a configured `diff.relative` would hide paths outside the
940
+ * cwd). --no-renames reports a rename as its deletion AND its addition, so a file
941
+ * moved into or out of a TP's globs touches it. Memoised per (claim, head); null
942
+ * when git could not answer.
943
+ *
944
+ * @param {Io} io
945
+ * @param {Repo} repo
946
+ * @param {string} claim
947
+ * @returns {readonly string[] | null}
948
+ */
949
+ function diffPaths(io, repo, claim) {
950
+ const key = claim + '..' + repo.head;
951
+ const memo = repo.diffs.get(key);
952
+ if (memo !== undefined) return memo;
953
+ const r = git(io, ['diff', '--no-renames', '--no-ext-diff', '--no-relative', '--name-only', '-z',
954
+ rev(claim), rev(repo.head), '--'], GIT_LOCAL_TIMEOUT_MS, DIFF_MAX_BUFFER);
955
+ const paths = r.ok ? Object.freeze(r.stdout.toString('utf8').split('\0').filter(p => p !== '')) : null;
956
+ repo.diffs.set(key, paths);
957
+ return paths;
958
+ }
959
+
960
+ // ---------------------------------------------------------------------------
961
+ // CI runs — gh run list, then gh run view --attempt per run
962
+ // ---------------------------------------------------------------------------
963
+
964
+ /**
965
+ * @typedef {{ id: number, attempt: number }} RunId
966
+ * @typedef {{ lists: Map<string, readonly RunId[] | null>, views: Map<string, object | null>,
967
+ * htmlUrl: string | null | undefined }} RunMemo
968
+ */
969
+
970
+ /** The largest run id kept (15 digits: exact as a JS number, pr-evidence's bound). */
971
+ const MAX_RUN_ID = 999999999999999;
972
+
973
+ /**
974
+ * @param {unknown} v
975
+ * @param {number} lo
976
+ * @param {number} hi
977
+ * @returns {boolean}
978
+ */
979
+ function isIntIn(v, lo, hi) {
980
+ return typeof v === 'number' && Number.isInteger(v) && v >= lo && v <= hi;
981
+ }
982
+
983
+ /**
984
+ * D-VERIFY-HTML-URL: the repository's html_url, read once and only when first
985
+ * needed (a ci TP's runs, or a FULL comment). A run's VERIFIED-CI depends on its
986
+ * URL lying under this value, so while it is unresolved no run can be verified:
987
+ * ci TPs then read their runs as unresolved (INDETERMINATE), not as a pass.
988
+ *
989
+ * @param {Io} io
990
+ * @param {RunMemo} memo
991
+ * @returns {string | null}
992
+ */
993
+ function htmlUrl(io, memo) {
994
+ if (memo.htmlUrl !== undefined) return memo.htmlUrl;
995
+ const r = gh(io, ['api', 'repos/{owner}/{repo}', '--jq', '.html_url'], LINE_MAX_BUFFER);
996
+ const value = r.ok ? r.stdout.toString('utf8').replace(/\n$/, '') : '';
997
+ memo.htmlUrl = /^\S{1,400}$/.test(value) ? value : null;
998
+ return memo.htmlUrl;
999
+ }
1000
+
1001
+ /**
1002
+ * The runs at a commit: `gh run list --commit <sha> --limit 20`, as run ids and
1003
+ * latest attempts. A list of exactly 20 may have been cut, so it is unresolved.
1004
+ * Memoised per SHA.
1005
+ *
1006
+ * @param {Io} io
1007
+ * @param {RunMemo} memo
1008
+ * @param {string} sha
1009
+ * @returns {readonly RunId[] | null}
1010
+ */
1011
+ function listRuns(io, memo, sha) {
1012
+ const cached = memo.lists.get(sha);
1013
+ if (cached !== undefined) return cached;
1014
+ const r = gh(io, ['run', 'list', '--commit', rev(sha), '--json', 'databaseId,attempt,workflowName',
1015
+ '--limit', String(PE.LIMITS.RUNS_PER_SHA)], RUN_LIST_MAX_BUFFER);
1016
+ /** @type {readonly RunId[] | null} */
1017
+ let runs = null;
1018
+ if (r.ok) {
1019
+ let json = null;
1020
+ try { json = JSON.parse(r.stdout.toString('utf8')); } catch (_) { json = null; }
1021
+ if (Array.isArray(json) && json.length < PE.LIMITS.RUNS_PER_SHA) {
1022
+ const parsed = json.map(e => ({ id: obj(e).databaseId, attempt: obj(e).attempt }));
1023
+ const ids = new Set(parsed.map(p => p.id));
1024
+ if (parsed.every(p => isIntIn(p.id, 1, MAX_RUN_ID) && isIntIn(p.attempt, 1, 999)) && ids.size === parsed.length) {
1025
+ runs = Object.freeze(parsed.map(p => Object.freeze({ id: /** @type {number} */ (p.id), attempt: /** @type {number} */ (p.attempt) }))
1026
+ .sort((a, b) => a.id - b.id));
1027
+ }
1028
+ }
1029
+ }
1030
+ memo.lists.set(sha, runs);
1031
+ return runs;
1032
+ }
1033
+
1034
+ /**
1035
+ * One attempt of one run: `gh run view <id> --attempt <n>`. A 404 is an expired
1036
+ * run (`expired: true` — INDETERMINATE, never a silent pass). The URL is kept only
1037
+ * when it names THIS run (…/actions/runs/<id>, optionally /attempts/<n>);
1038
+ * whether it lies under the repository is `classify`'s isRepoLink. The head SHA
1039
+ * is kept only as 40 hex. Memoised per (id, attempt); null when unresolved.
1040
+ *
1041
+ * @param {Io} io
1042
+ * @param {RunMemo} memo
1043
+ * @param {RunId} run
1044
+ * @returns {object | null}
1045
+ */
1046
+ function viewRun(io, memo, run) {
1047
+ const key = run.id + '/' + run.attempt;
1048
+ if (memo.views.has(key)) return /** @type {object | null} */ (memo.views.get(key));
1049
+ if (io.budget.runViews >= CAPS.RUN_VIEWS) {
1050
+ io.notes.add('run-view-cap');
1051
+ return null;
1052
+ }
1053
+ io.budget.runViews++;
1054
+ const r = gh(io, ['run', 'view', String(run.id), '--attempt', String(run.attempt), '--json',
1055
+ 'headSha,conclusion,status,url'], RUN_VIEW_MAX_BUFFER);
1056
+ /** @type {object | null} */
1057
+ let view = null;
1058
+ if (r.ok) {
1059
+ let json = null;
1060
+ try { json = JSON.parse(r.stdout.toString('utf8')); } catch (_) { json = null; }
1061
+ const o = obj(json);
1062
+ const status = typeof o.status === 'string' && o.status.length <= MAX_TOKEN_CHARS ? o.status : null;
1063
+ const conclusion = o.conclusion === null ? null
1064
+ : typeof o.conclusion === 'string' && o.conclusion.length <= MAX_TOKEN_CHARS ? o.conclusion : undefined;
1065
+ if (status !== null && conclusion !== undefined) {
1066
+ const url = typeof o.url === 'string' && o.url.length <= MAX_URL_CHARS
1067
+ && (o.url.endsWith('/actions/runs/' + run.id) || o.url.endsWith('/actions/runs/' + run.id + '/attempts/' + run.attempt))
1068
+ ? o.url : '';
1069
+ const headSha = typeof o.headSha === 'string' && SHA40_RE.test(o.headSha) ? o.headSha : '';
1070
+ view = Object.freeze({ id: run.id, attempt: run.attempt, status, conclusion, headSha, url });
1071
+ }
1072
+ } else if (answered(r) && /HTTP 404\b/.test(r.stderr)) {
1073
+ view = Object.freeze({ id: run.id, attempt: run.attempt, expired: true });
1074
+ }
1075
+ if (r.refused === null) memo.views.set(key, view);
1076
+ return view;
1077
+ }
1078
+
1079
+ /**
1080
+ * The latest attempt of every run at `sha`, or null when any part is unresolved —
1081
+ * the list, any view, or a view budget too small for the whole list (a partial
1082
+ * set could hide the one failing run).
1083
+ *
1084
+ * @param {Io} io
1085
+ * @param {RunMemo} memo
1086
+ * @param {string} sha
1087
+ * @returns {readonly object[] | null}
1088
+ */
1089
+ function runsAt(io, memo, sha) {
1090
+ const list = listRuns(io, memo, sha);
1091
+ if (list === null) return null;
1092
+ const unseen = list.filter(r => !memo.views.has(r.id + '/' + r.attempt)).length;
1093
+ if (unseen > CAPS.RUN_VIEWS - io.budget.runViews) {
1094
+ io.notes.add('run-view-cap');
1095
+ return null;
1096
+ }
1097
+ const views = [];
1098
+ for (const run of list) {
1099
+ const v = viewRun(io, memo, run);
1100
+ if (v === null) return null;
1101
+ views.push(v);
1102
+ }
1103
+ return Object.freeze(views);
1104
+ }
1105
+
1106
+ // ---------------------------------------------------------------------------
1107
+ // Plan, claims and exceptions
1108
+ // ---------------------------------------------------------------------------
1109
+
1110
+ /**
1111
+ * @typedef {{ plan: { tps: readonly any[] } | null, claims: Map<number, object>, exceptions: readonly any[] | null,
1112
+ * malformed: number }} EvidenceInput
1113
+ */
1114
+
1115
+ /**
1116
+ * Read `--evidence <file>`: its `## Test Plan` (optional; a heading with no TP line
1117
+ * is an empty plan — "this change has no test plan" — not an error), `## Claims`
1118
+ * (optional; the last valid claim per TP wins) and `## Evidence Exceptions`
1119
+ * (optional).
1120
+ *
1121
+ * @param {string} file
1122
+ * @returns {EvidenceInput | null} null when unusable
1123
+ */
1124
+ function loadEvidence(file) {
1125
+ const text = readTextFile(file);
1126
+ if (text === null) return null;
1127
+ const sections = PE.evidenceSections(text);
1128
+ if (!sections.ok) return null;
1129
+ let plan = null;
1130
+ if (sections.value.testPlan !== null) {
1131
+ const p = PE.parsePlan(sections.value.testPlan);
1132
+ if (p.ok) plan = p.value;
1133
+ else if (p.error.code === 'empty') plan = { tps: [] };
1134
+ else return null;
1135
+ }
1136
+ const claims = PE.parseClaims(sections.value.claims === null ? '' : sections.value.claims);
1137
+ if (!claims.ok) return null;
1138
+ let exceptions = null;
1139
+ if (sections.value.exceptions !== null) {
1140
+ const e = PE.parseExceptions(sections.value.exceptions);
1141
+ if (!e.ok) return null;
1142
+ exceptions = e.value;
1143
+ }
1144
+ return { plan, claims: claims.value.tp, exceptions, malformed: claims.value.malformed };
1145
+ }
1146
+
1147
+ /**
1148
+ * The plan when it comes from the PR body: the test-plan block's TP lines.
1149
+ * No block is an empty plan; a malformed or counts-only block cannot serve as a
1150
+ * plan (its TP text is not there) — null, input unusable.
1151
+ *
1152
+ * @param {string} body
1153
+ * @returns {{ tps: readonly any[] } | null}
1154
+ */
1155
+ function bodyPlan(body) {
1156
+ const found = PE.findBlock(body);
1157
+ if (!found.ok) return null;
1158
+ if (found.value === null) return { tps: [] };
1159
+ const parsed = PE.parseBlock(found.value);
1160
+ if (!parsed.ok || parsed.value.kind !== 'lines') return null;
1161
+ return parsed.value.plan;
1162
+ }
1163
+
1164
+ /**
1165
+ * D-VERIFY-RECORD: a trusted record's line as the claim it rests on — its SHA, its
1166
+ * outcome (`out:`, D-RECORD-OUTCOME) and its exit code; `sha:none` is no claim.
1167
+ * The recorded STATE is never carried forward: `classify` re-derives it from the
1168
+ * facts as they are now, exactly as for a new claim. So a record written while CI
1169
+ * was pending (INDETERMINATE) reads VERIFIED-CI once the run succeeds, or FAILED
1170
+ * once it fails; a STALE claim whose files the diff no longer touches is decided
1171
+ * again; and a recorded FAIL or SKIP never passes. The record reaches here only
1172
+ * from a trusted author (findTrustedRecord) and only for TP text whose hash it
1173
+ * carries (tpInputs), and the parser has refused any record whose outcome
1174
+ * contradicts its state.
1175
+ *
1176
+ * @param {any} record a parseEvidenceComment TpRecord
1177
+ * @returns {object | null} a Claim, or null for no claim
1178
+ */
1179
+ function recordClaim(record) {
1180
+ if (record.sha === null) return null;
1181
+ return Object.freeze({
1182
+ target: 'TP-' + record.id,
1183
+ tp: record.id,
1184
+ gate: null,
1185
+ outcome: record.outcome,
1186
+ sha: record.sha,
1187
+ by: 'test',
1188
+ exit: record.exit,
1189
+ });
1190
+ }
1191
+
1192
+ // ---------------------------------------------------------------------------
1193
+ // Classification — gather lazily, in ladder order; `classify` decides
1194
+ // ---------------------------------------------------------------------------
1195
+
1196
+ /**
1197
+ * @typedef {{ tp: any, hash: string, recorded: boolean, claim: object | null,
1198
+ * textMatches: boolean | undefined, forceIndeterminate: boolean }} TpInput
1199
+ * `recorded`: the trusted record carries this TP with the same text hash.
1200
+ */
1201
+
1202
+ /**
1203
+ * The facts for one TP, gathered in the order `classify` consults them and no
1204
+ * further than the first arm that can already decide: nothing past a missing or
1205
+ * SKIP claim (a text mismatch attaches none, see tpInputs) or an unresolved head;
1206
+ * nothing past an ancestry answer other than "in the PR". Stopping early can only
1207
+ * leave a fact absent, and an absent fact reads unresolved — never a pass.
1208
+ *
1209
+ * @param {Io} io
1210
+ * @param {Repo} repo
1211
+ * @param {RunMemo} memo
1212
+ * @param {TpInput} x
1213
+ * @returns {object}
1214
+ */
1215
+ function gatherFacts(io, repo, memo, x) {
1216
+ const claim = /** @type {any} */ (x.claim);
1217
+ /** @type {Record<string, unknown>} */
1218
+ const facts = { claim, head: repo.headResolved ? repo.head : null, inPr: null };
1219
+ if (x.textMatches !== undefined) facts.textMatches = x.textMatches;
1220
+ if (claim === null || claim.outcome === 'SKIP' || !repo.headResolved) return facts;
1221
+ facts.inPr = inPr(io, repo, claim.sha);
1222
+ if (facts.inPr !== true) return facts;
1223
+ if (claim.sha !== repo.head && x.tp.files.length > 0) facts.diff = diffPaths(io, repo, claim.sha);
1224
+ if (x.tp.method === 'ci') {
1225
+ const url = htmlUrl(io, memo);
1226
+ facts.verifyingSha = claim.sha;
1227
+ if (url === null) facts.runs = null;
1228
+ else {
1229
+ facts.htmlUrl = url;
1230
+ facts.runs = runsAt(io, memo, claim.sha);
1231
+ }
1232
+ }
1233
+ return facts;
1234
+ }
1235
+
1236
+ /**
1237
+ * D-VERIFY-VERIFYING-SHA: a ci claim is verified by the runs at its own SHA. When
1238
+ * that SHA has no runs at all — a commit that was never a pushed tip — and the
1239
+ * change since does not touch the TP (so `classify` answered ATTESTED-LOCAL with
1240
+ * run:none), the head's runs are consulted instead, as §3.3 allows. The head's
1241
+ * answer replaces the first only when the head's runs resolved.
1242
+ *
1243
+ * @param {Io} io
1244
+ * @param {Repo} repo
1245
+ * @param {RunMemo} memo
1246
+ * @param {TpInput} x
1247
+ * @returns {{ state: string, sha: string | null, outcome: string | null, run: any, exit: number | null }}
1248
+ */
1249
+ function classifyTp(io, repo, memo, x) {
1250
+ // D-VERIFY-THROTTLE: the record that would decide this TP could not be read, so
1251
+ // no claim rests on it (tpInputs attaches none) — INDETERMINATE, sha:none.
1252
+ if (x.forceIndeterminate) return { state: 'INDETERMINATE', sha: null, outcome: null, run: null, exit: null };
1253
+ const facts = gatherFacts(io, repo, memo, x);
1254
+ const verdict = PE.classify(x.tp, facts);
1255
+ const claim = /** @type {any} */ (x.claim);
1256
+ if (x.tp.method === 'ci' && verdict.state === 'ATTESTED-LOCAL' && verdict.run === 'none' && claim.sha !== repo.head) {
1257
+ const headRuns = runsAt(io, memo, repo.head);
1258
+ if (headRuns !== null) return PE.classify(x.tp, { ...facts, verifyingSha: repo.head, runs: headRuns });
1259
+ }
1260
+ return verdict;
1261
+ }
1262
+
1263
+ // ---------------------------------------------------------------------------
1264
+ // verify
1265
+ // ---------------------------------------------------------------------------
1266
+
1267
+ /**
1268
+ * @param {string} publication
1269
+ * @returns {'full' | 'off' | 'stub'}
1270
+ */
1271
+ function publicationMode(publication) {
1272
+ // D-VERIFY-PUBLICATION (D4): `full` ⇒ FULL, `off` ⇒ no comment, anything else —
1273
+ // `auto` included — ⇒ STUB. No second visibility probe is made here.
1274
+ if (publication === 'full') return 'full';
1275
+ if (publication === 'off') return 'off';
1276
+ return 'stub';
1277
+ }
1278
+
1279
+ /**
1280
+ * The claims, hashes and text checks for every TP of the plan (D-VERIFY-RECORD):
1281
+ * a file claim wins; else the trusted record's, when the record's `h:` equals the
1282
+ * TP's hash. A body-sourced TP's text counts only when that hash matches — and a
1283
+ * claim attaches only to text that counts: body text that fails the check gets no
1284
+ * claim at all, not even the file's. The file's claim was made against the text
1285
+ * the record published, not this text, and a record binding the two would pass
1286
+ * the next refresh's hash check and launder the claim onto it. A TP whose record
1287
+ * is unknown (D-VERIFY-THROTTLE) gets no claim either. Null when a TP line fails
1288
+ * its grammar (the hash is refused).
1289
+ *
1290
+ * @param {{ tps: readonly any[] }} plan
1291
+ * @param {EvidenceInput | null} evidence
1292
+ * @param {TrustedRecord} record
1293
+ * @param {boolean} fromFile
1294
+ * @returns {TpInput[] | null}
1295
+ */
1296
+ function tpInputs(plan, evidence, record, fromFile) {
1297
+ const recordById = recordsById(record);
1298
+ /** @type {TpInput[]} */
1299
+ const inputs = [];
1300
+ for (const tp of plan.tps) {
1301
+ const hash = PE.tpHash(tp, sha256);
1302
+ if (!hash.ok) return null;
1303
+ const rec = recordById.get(tp.id);
1304
+ const recordApplies = rec !== undefined && rec.hash === hash.value;
1305
+ const fileClaim = evidence === null ? undefined : evidence.claims.get(tp.id);
1306
+ const textMatches = fromFile ? undefined : recordApplies;
1307
+ // D-VERIFY-THROTTLE: when the record itself is unknown, so is every claim or
1308
+ // text check that would have come from it.
1309
+ const forceIndeterminate = record.kind === 'unknown' && (!fromFile || fileClaim === undefined);
1310
+ const claim = textMatches === false || forceIndeterminate ? null
1311
+ : fileClaim !== undefined ? fileClaim
1312
+ : recordApplies ? recordClaim(rec) : null;
1313
+ inputs.push({ tp, hash: hash.value, recorded: recordApplies, claim, textMatches, forceIndeterminate });
1314
+ }
1315
+ return inputs;
1316
+ }
1317
+
1318
+ /**
1319
+ * @param {TrustedRecord} record
1320
+ * @returns {Map<number, any>}
1321
+ */
1322
+ function recordsById(record) {
1323
+ return new Map(record.kind === 'record' ? record.records.map(r => [r.id, r]) : []);
1324
+ }
1325
+
1326
+ /**
1327
+ * D-VERIFY-RECORD: whether a PR-body plan still carries every TP the trusted record
1328
+ * does. The hash check guards the text of each TP the body shows, but not the TPs
1329
+ * it leaves out: anyone who can edit the body — a fork author, whom the trust rule
1330
+ * never trusts — could delete the failing lines so that the verified rest equals
1331
+ * the total. A body plan that drops a recorded TP therefore cannot serve as the
1332
+ * plan at all (input unusable), rather than yield a smaller total. A plan read from
1333
+ * the evidence file is local text and is not held to the record.
1334
+ *
1335
+ * @param {{ tps: readonly any[] }} plan
1336
+ * @param {TrustedRecord} record
1337
+ * @returns {boolean}
1338
+ */
1339
+ function coversRecord(plan, record) {
1340
+ if (record.kind !== 'record') return true;
1341
+ const ids = new Set(plan.tps.map(tp => tp.id));
1342
+ return record.records.every(r => ids.has(r.id));
1343
+ }
1344
+
1345
+ /**
1346
+ * The body update: the block (or its counts-only form, D-SPLICE) spliced into the
1347
+ * body. When it cannot be spliced — malformed markers, oversize even as counts —
1348
+ * the body reads `changed`, so the caller runs `splice`, which names the refusal.
1349
+ * Null when a block fails to render.
1350
+ *
1351
+ * @param {Io} io
1352
+ * @param {{ tps: readonly any[] }} plan
1353
+ * @param {any} ev
1354
+ * @param {string} body
1355
+ * @returns {{ blockText: string, changed: boolean } | null}
1356
+ */
1357
+ function bodyUpdate(io, plan, ev, body) {
1358
+ if (plan.tps.length === 0) return { blockText: '', changed: false };
1359
+ const full = PE.render(plan, ev, 'block');
1360
+ const counts = PE.render(plan, ev, 'counts');
1361
+ if (!full.ok || !counts.ok) return null;
1362
+ const fit = PE.spliceFit(body, [full.value.text, counts.value.text]);
1363
+ if (fit.ok) {
1364
+ return { blockText: fit.value.index === 0 ? full.value.text : counts.value.text, changed: fit.value.body !== body };
1365
+ }
1366
+ io.notes.add('body-' + fit.error.code);
1367
+ return { blockText: fit.error.code === 'oversize' ? counts.value.text : full.value.text, changed: true };
1368
+ }
1369
+
1370
+ /**
1371
+ * The comment (D-VERIFY-PUBLICATION) and whether it is already posted: only the
1372
+ * viewer's own comment whose first line is this exact marker (head and key)
1373
+ * counts, so a spoofed marker never suppresses a post. `off`, or nothing to
1374
+ * record, is no comment (`n/a`). Null when it fails to render.
1375
+ *
1376
+ * @param {{ tps: readonly any[] }} plan
1377
+ * @param {any} ev
1378
+ * @param {'full' | 'off' | 'stub'} mode
1379
+ * @param {PrFacts} pr
1380
+ * @returns {{ text: string, posted: 'yes' | 'no' | 'n/a' } | null}
1381
+ */
1382
+ function commentUpdate(plan, ev, mode, pr) {
1383
+ if (mode === 'off' || (ev.records.length === 0 && ev.exceptions.length === 0)) return { text: '', posted: 'n/a' };
1384
+ const rendered = PE.render(plan, ev, mode);
1385
+ if (!rendered.ok) return null;
1386
+ const marker = firstLine(rendered.value.text);
1387
+ const posted = pr.comments.some(c => c.viewerDidAuthor && firstLine(c.body) === marker) ? 'yes' : 'no';
1388
+ return { text: rendered.value.text, posted };
1389
+ }
1390
+
1391
+ /**
1392
+ * --stale-out: only STALE TPs whose text hash equals the trusted record's — text a
1393
+ * trusted author already published, never unrecorded PR text (containment).
1394
+ *
1395
+ * @param {readonly TpInput[]} inputs
1396
+ * @param {readonly { state: string }[]} records
1397
+ * @returns {string}
1398
+ */
1399
+ function staleOutText(inputs, records) {
1400
+ const lines = inputs.filter((x, i) => records[i].state === 'STALE' && x.recorded).map(x => x.tp.line);
1401
+ return lines.length === 0 ? '' : ['## Test Plan', ...lines].join('\n') + '\n';
1402
+ }
1403
+
1404
+ /**
1405
+ * Write every requested output, or report that one failed (nothing is printed then).
1406
+ *
1407
+ * @param {Extract<ParsedArgs, { kind: 'verify' }>} args
1408
+ * @param {string} body
1409
+ * @param {string} blockText
1410
+ * @param {string} commentText
1411
+ * @param {string} staleText
1412
+ * @returns {boolean}
1413
+ */
1414
+ function writeOutputs(args, body, blockText, commentText, staleText) {
1415
+ /** @type {Array<[string | null, string]>} */
1416
+ const writes = [
1417
+ [args.state === null ? null : path.join(args.state, 'base'), body],
1418
+ [args.state === null ? null : path.join(args.state, 'base.sha256'), sha256(body) + '\n'],
1419
+ [args.blockOut, blockText === '' ? '' : blockText + '\n'],
1420
+ [args.commentOut, commentText === '' ? '' : commentText + '\n'],
1421
+ [args.staleOut, staleText],
1422
+ ];
1423
+ return writes.every(([file, text]) => file === null || writeTextFile(file, text));
1424
+ }
1425
+
1426
+ /**
1427
+ * @param {Io} io
1428
+ * @param {Extract<ParsedArgs, { kind: 'verify' }>} args
1429
+ * @param {MainDeps} deps
1430
+ * @returns {Outcome}
1431
+ */
1432
+ function runVerify(io, args, deps) {
1433
+ // Inputs first: an unusable evidence file or state directory decides before any call.
1434
+ /** @type {EvidenceInput | null} */
1435
+ let evidence = null;
1436
+ if (args.evidence !== null) {
1437
+ evidence = loadEvidence(args.evidence);
1438
+ if (evidence === null) return refuse(io, EXIT_CODES.INPUT_UNUSABLE, 'evidence file unusable');
1439
+ if (evidence.malformed > 0) io.notes.add('malformed-claims');
1440
+ }
1441
+ if (args.state !== null && !isRealDirectory(args.state)) return refuse(io, EXIT_CODES.INPUT_UNUSABLE, 'state directory unusable');
1442
+
1443
+ const pr = parsePrJson(prView(io, args.pr, PR_FIELDS), args.pr);
1444
+ if (pr === null) return refuse(io, EXIT_CODES.REMOTE_FAILURE, 'the PR could not be read');
1445
+
1446
+ const trust = resolveTrust(io, pr, args.approval);
1447
+ const record = findTrustedRecord(pr, trust);
1448
+ if (record.kind === 'unknown') io.notes.add('record-unknown');
1449
+
1450
+ // D-VERIFY-RECORD: the plan is the evidence file's when it has one (local text),
1451
+ // else the PR body's block — whose TP text counts only where its hash equals the
1452
+ // trusted record's.
1453
+ const fromFile = evidence !== null && evidence.plan !== null;
1454
+ const plan = fromFile ? /** @type {any} */ (evidence).plan : bodyPlan(pr.body);
1455
+ if (plan === null) return refuse(io, EXIT_CODES.INPUT_UNUSABLE, 'the PR body test-plan block cannot serve as the plan');
1456
+ if (!fromFile && !coversRecord(plan, record)) {
1457
+ return refuse(io, EXIT_CODES.INPUT_UNUSABLE, 'the PR body test-plan block drops a TP the trusted record carries');
1458
+ }
1459
+ const inputs = tpInputs(plan, evidence, record, fromFile);
1460
+ if (inputs === null) return refuse(io, EXIT_CODES.OUTPUT_GATE_REFUSED, 'a TP line failed its grammar');
1461
+
1462
+ const repo = prepareRepo(io, pr);
1463
+ /** @type {RunMemo} */
1464
+ const memo = { lists: new Map(), views: new Map(), htmlUrl: undefined };
1465
+ const records = inputs.map(x => {
1466
+ const v = classifyTp(io, repo, memo, x);
1467
+ return Object.freeze({ id: x.tp.id, state: v.state, sha: v.sha, outcome: v.outcome, run: v.run, exit: v.exit, hash: x.hash });
1468
+ });
1469
+
1470
+ const exceptions = evidence !== null && evidence.exceptions !== null ? evidence.exceptions
1471
+ : record.kind === 'record' ? record.exceptions : [];
1472
+ const key = PE.dedupeKey({ records, exceptions }, sha256);
1473
+ const tally = PE.tally(records.map(r => r.state));
1474
+ if (!key.ok || !tally.ok) return refuse(io, EXIT_CODES.OUTPUT_GATE_REFUSED, 'the records failed their grammar');
1475
+
1476
+ const mode = publicationMode(args.publication);
1477
+ const ev = {
1478
+ head: pr.head,
1479
+ key: key.value,
1480
+ htmlUrl: mode === 'full' && records.length > 0 ? htmlUrl(io, memo) || '' : '',
1481
+ records,
1482
+ exceptions,
1483
+ };
1484
+ const body = bodyUpdate(io, plan, ev, pr.body);
1485
+ const comment = commentUpdate(plan, ev, mode, pr);
1486
+ if (body === null || comment === null) return refuse(io, EXIT_CODES.OUTPUT_GATE_REFUSED, 'the block or the comment failed to render');
1487
+
1488
+ const line = gateEvidenceLine(deps, {
1489
+ pr: pr.number,
1490
+ head: pr.head,
1491
+ total: tally.value.total,
1492
+ counts: { ...tally.value.counts },
1493
+ stale: records.filter(r => r.state === 'STALE').map(r => r.id),
1494
+ exceptions: PE.EXCEPTION_KINDS.filter(k => exceptions.some(e => e.kind === k)),
1495
+ approval: args.approval ? approvalOf(pr, trust) : 'unchecked',
1496
+ key: key.value,
1497
+ posted: comment.posted,
1498
+ body: body.changed ? 'changed' : 'same',
1499
+ }, plan.tps.length);
1500
+ if (line === null) return refuse(io, EXIT_CODES.OUTPUT_GATE_REFUSED, 'the EVIDENCE line failed its gate');
1501
+
1502
+ if (!writeOutputs(args, pr.body, body.blockText, comment.text, staleOutText(inputs, records))) {
1503
+ return refuse(io, EXIT_CODES.WRITE_FAILED, 'an output file could not be written');
1504
+ }
1505
+ return { code: EXIT_CODES.OK, stdout: line + '\n' };
1506
+ }
1507
+
1508
+ /**
1509
+ * THE GATE on the EVIDENCE line: it must format (counts sum to total, `stale`
1510
+ * lists exactly the STALE ids ascending, kinds in vocabulary order), re-parse to
1511
+ * the identical line, and carry the plan's TP count as its total.
1512
+ *
1513
+ * @param {MainDeps} deps
1514
+ * @param {object} fields
1515
+ * @param {number} total
1516
+ * @returns {string | null}
1517
+ */
1518
+ function gateEvidenceLine(deps, fields, total) {
1519
+ let formatted;
1520
+ try {
1521
+ formatted = typeof deps.formatLine === 'function' ? deps.formatLine(fields) : PE.formatEvidenceLine(fields);
1522
+ } catch (_) {
1523
+ return null;
1524
+ }
1525
+ const f = /** @type {any} */ (formatted);
1526
+ const line = typeof f === 'string' ? f : f !== null && typeof f === 'object' && f.ok === true ? f.value : null;
1527
+ if (typeof line !== 'string') return null;
1528
+ const parsed = PE.parseEvidenceLine(line);
1529
+ return parsed.ok && parsed.value.total === total ? line : null;
1530
+ }
1531
+
1532
+ // ---------------------------------------------------------------------------
1533
+ // splice — the compare-and-swap (D-VERIFY-CAS, applies ADR-023 and ADR-024)
1534
+ // ---------------------------------------------------------------------------
1535
+
1536
+ /**
1537
+ * D-VERIFY-CAS: re-read the body. Equal to verify's snapshot ⇒ splice the block
1538
+ * into it (`ok`). Different ⇒ splice once onto the fresh body and re-read: still
1539
+ * the same ⇒ `resplice`; changed again ⇒ `conflict` and no output. Only the bytes
1540
+ * between the markers are devflow's. GitHub has no conditional body edit, so an
1541
+ * edit landing between the last read and `gh pr edit` is still overwritten (it
1542
+ * stays in the PR's edit history); the read-back proves only our bytes landed.
1543
+ *
1544
+ * @param {Io} io
1545
+ * @param {Extract<ParsedArgs, { kind: 'splice' }>} args
1546
+ * @returns {Outcome}
1547
+ */
1548
+ function runSplice(io, args) {
1549
+ if (!isRealDirectory(args.state)) return refuse(io, EXIT_CODES.INPUT_UNUSABLE, 'state directory unusable');
1550
+ const base = readTextFile(path.join(args.state, 'base'));
1551
+ const recorded = readTextFile(path.join(args.state, 'base.sha256'));
1552
+ if (base === null || recorded === null || recorded !== sha256(base) + '\n') {
1553
+ return refuse(io, EXIT_CODES.INPUT_UNUSABLE, 'the body snapshot is missing or does not match its hash');
1554
+ }
1555
+ const blockFile = readTextFile(args.block);
1556
+ if (blockFile === null) return refuse(io, EXIT_CODES.INPUT_UNUSABLE, 'block file unusable');
1557
+ const block = stripOneNewline(blockFile);
1558
+
1559
+ const first = readBody(io, args.pr);
1560
+ if (first === null) return refuse(io, EXIT_CODES.REMOTE_FAILURE, 'the PR body could not be read');
1561
+ const spliced = PE.splice(first, block);
1562
+ if (!spliced.ok) return spliceOutcome(spliced.error.code === 'oversize' ? 'oversize' : 'malformed');
1563
+ if (spliced.value.length > PE.LIMITS.BODY_CHARS) return spliceOutcome('oversize');
1564
+ let outcome = 'ok';
1565
+ if (first !== base) {
1566
+ const second = readBody(io, args.pr);
1567
+ if (second === null) return refuse(io, EXIT_CODES.REMOTE_FAILURE, 'the PR body could not be re-read');
1568
+ if (second !== first) return spliceOutcome('conflict');
1569
+ outcome = 'resplice';
1570
+ }
1571
+ if (!writeTextFile(args.out, spliced.value)) return refuse(io, EXIT_CODES.WRITE_FAILED, 'the body file could not be written');
1572
+ return spliceOutcome(outcome);
1573
+ }
1574
+
1575
+ /**
1576
+ * @param {string} outcome
1577
+ * @returns {Outcome}
1578
+ */
1579
+ function spliceOutcome(outcome) {
1580
+ const code = outcome === 'ok' || outcome === 'resplice' ? EXIT_CODES.OK : EXIT_CODES.OUTPUT_GATE_REFUSED;
1581
+ return { code, stdout: 'SPLICE ' + outcome + '\n' };
1582
+ }
1583
+
1584
+ /**
1585
+ * readback: the body equals the expected file after normalising one trailing
1586
+ * newline on each side.
1587
+ *
1588
+ * @param {Io} io
1589
+ * @param {Extract<ParsedArgs, { kind: 'readback' }>} args
1590
+ * @returns {Outcome}
1591
+ */
1592
+ function runReadback(io, args) {
1593
+ const expected = readTextFile(args.expect);
1594
+ if (expected === null) return refuse(io, EXIT_CODES.INPUT_UNUSABLE, 'expected-body file unusable');
1595
+ const body = readBody(io, args.pr);
1596
+ if (body === null) return refuse(io, EXIT_CODES.REMOTE_FAILURE, 'the PR body could not be read');
1597
+ return stripOneNewline(body) === stripOneNewline(expected)
1598
+ ? { code: EXIT_CODES.OK, stdout: 'READBACK ok\n' }
1599
+ : { code: EXIT_CODES.OUTPUT_GATE_REFUSED, stdout: 'READBACK mismatch\n' };
1600
+ }
1601
+
1602
+ // ---------------------------------------------------------------------------
1603
+ // check and render — no subprocess at all
1604
+ // ---------------------------------------------------------------------------
1605
+
1606
+ /**
1607
+ * The text a plan or exceptions check reads: the named section of an evidence
1608
+ * file, or the whole file when it has no such section.
1609
+ *
1610
+ * @param {string} text
1611
+ * @param {'testPlan' | 'exceptions'} section
1612
+ * @returns {string}
1613
+ */
1614
+ function sectionOrWhole(text, section) {
1615
+ const s = PE.evidenceSections(text);
1616
+ return s.ok && s.value[section] !== null ? /** @type {string} */ (s.value[section]) : text;
1617
+ }
1618
+
1619
+ /**
1620
+ * `check wave` reads the whole file: a wave block is a PR-body section of its
1621
+ * own, never a section of an evidence file.
1622
+ *
1623
+ * @param {Io} io
1624
+ * @param {Extract<ParsedArgs, { kind: 'check' }>} args
1625
+ * @returns {Outcome}
1626
+ */
1627
+ function runCheck(io, args) {
1628
+ const text = readTextFile(args.file);
1629
+ if (text === null) return refuse(io, EXIT_CODES.INPUT_UNUSABLE, 'file unusable');
1630
+ let result;
1631
+ if (args.what === 'tp') result = PE.parsePlan(sectionOrWhole(text, 'testPlan'));
1632
+ else if (args.what === 'exceptions') result = PE.parseExceptions(sectionOrWhole(text, 'exceptions'));
1633
+ else if (args.what === 'wave') result = PE.parseWaveBlock(text);
1634
+ else {
1635
+ const b = PE.parseBlock(text);
1636
+ // R7 pastes only the creation form: TP lines, none ticked.
1637
+ result = b.ok && (b.value.kind !== 'lines' || b.value.ticked.length > 0) ? { ok: false, error: { code: 'invalid', line: 0 } } : b;
1638
+ }
1639
+ if (result.ok) return { code: EXIT_CODES.OK, stdout: '' };
1640
+ io.stderr('verify-evidence: check ' + args.what + ': ' + result.error.code
1641
+ + (result.error.line > 0 ? ' at line ' + result.error.line : '') + '\n');
1642
+ return { code: EXIT_CODES.OUTPUT_GATE_REFUSED, stdout: '' };
1643
+ }
1644
+
1645
+ /**
1646
+ * @param {Io} io
1647
+ * @param {Extract<ParsedArgs, { kind: 'render' }>} args
1648
+ * @returns {Outcome}
1649
+ */
1650
+ function runRender(io, args) {
1651
+ const text = readTextFile(args.plan);
1652
+ if (text === null) return refuse(io, EXIT_CODES.INPUT_UNUSABLE, 'plan file unusable');
1653
+ const plan = PE.parsePlan(sectionOrWhole(text, 'testPlan'));
1654
+ if (!plan.ok) {
1655
+ return refuse(io, EXIT_CODES.INPUT_UNUSABLE, 'the plan failed its grammar (' + plan.error.code
1656
+ + (plan.error.line > 0 ? ' at line ' + plan.error.line : '') + ')');
1657
+ }
1658
+ const block = PE.render(plan.value, null, 'create');
1659
+ if (!block.ok) return refuse(io, EXIT_CODES.OUTPUT_GATE_REFUSED, 'the block failed to render');
1660
+ return { code: EXIT_CODES.OK, stdout: block.value.text + '\n' };
1661
+ }
1662
+
1663
+ // ---------------------------------------------------------------------------
1664
+ // main — returns {code, stdout}; never calls process.exit
1665
+ // ---------------------------------------------------------------------------
1666
+
1667
+ /**
1668
+ * A refusal: a closed diagnostic on stderr, nothing on stdout.
1669
+ *
1670
+ * @param {Io} io
1671
+ * @param {number} code
1672
+ * @param {string} reason a fixed phrase from this file, never input bytes
1673
+ * @returns {Outcome}
1674
+ */
1675
+ function refuse(io, code, reason) {
1676
+ io.stderr('verify-evidence: ' + reason + '\n');
1677
+ return { code, stdout: '' };
1678
+ }
1679
+
1680
+ /**
1681
+ * The environment every subprocess gets: the caller's, with prompts disabled and
1682
+ * the variables that would colour or reshape gh/git output removed.
1683
+ *
1684
+ * @returns {NodeJS.ProcessEnv}
1685
+ */
1686
+ function childEnv() {
1687
+ const env = Object.assign({}, process.env, {
1688
+ GH_PROMPT_DISABLED: '1',
1689
+ GH_NO_UPDATE_NOTIFIER: '1',
1690
+ GIT_TERMINAL_PROMPT: '0',
1691
+ GIT_NO_REPLACE_OBJECTS: '1',
1692
+ NO_COLOR: '1',
1693
+ });
1694
+ delete env.GH_FORCE_TTY;
1695
+ delete env.CLICOLOR_FORCE;
1696
+ return env;
1697
+ }
1698
+
1699
+ /**
1700
+ * The production exec: spawnSync, read off the module object at call time.
1701
+ *
1702
+ * @type {ExecFn}
1703
+ */
1704
+ function defaultExec(file, args, opts) {
1705
+ return childProcess.spawnSync(file, args, /** @type {any} */ (opts));
1706
+ }
1707
+
1708
+ /**
1709
+ * @param {readonly string[]} argv process.argv
1710
+ * @param {MainDeps} [deps]
1711
+ * @returns {Outcome}
1712
+ */
1713
+ function main(argv, deps) {
1714
+ const d = deps || {};
1715
+ const now = typeof d.now === 'function' ? d.now : Date.now;
1716
+ /** @type {Io} */
1717
+ const io = {
1718
+ exec: typeof d.exec === 'function' ? d.exec : defaultExec,
1719
+ env: childEnv(),
1720
+ cwd: typeof d.cwd === 'string' ? d.cwd : process.cwd(),
1721
+ now,
1722
+ deadline: now() + DEADLINE_MS,
1723
+ budget: { ghCalls: 0, runViews: 0, throttled: false },
1724
+ notes: new Set(),
1725
+ stderr: typeof d.stderr === 'function' ? d.stderr : (text => { process.stderr.write(text); }),
1726
+ };
1727
+ const args = parseArgs(argv);
1728
+ if (args.kind === 'usage') {
1729
+ io.stderr(USAGE + '\n');
1730
+ return { code: EXIT_CODES.USAGE, stdout: '' };
1731
+ }
1732
+ /** @type {Outcome} */
1733
+ let outcome;
1734
+ try {
1735
+ if (args.kind === 'check') outcome = runCheck(io, args);
1736
+ else if (args.kind === 'render') outcome = runRender(io, args);
1737
+ else if (args.kind === 'verify') outcome = runVerify(io, args, d);
1738
+ else if (args.kind === 'splice') outcome = runSplice(io, args);
1739
+ else outcome = runReadback(io, args);
1740
+ } catch (err) {
1741
+ // An injected exec that throws, or a broken invariant (rev): no verdict.
1742
+ outcome = refuse(io, EXIT_CODES.REMOTE_FAILURE, 'internal error (' + errorLabel(err) + ')');
1743
+ }
1744
+ if (io.notes.size > 0) io.stderr('verify-evidence: notes: ' + [...io.notes].sort().join(',') + '\n');
1745
+ return outcome;
1746
+ }
1747
+
1748
+ /**
1749
+ * A short, safe label for a thrown value — its class name, never its message (a
1750
+ * message can quote input bytes).
1751
+ *
1752
+ * @param {unknown} err
1753
+ * @returns {string}
1754
+ */
1755
+ function errorLabel(err) {
1756
+ const e = /** @type {any} */ (err);
1757
+ return (e && typeof e.name === 'string' ? e.name : 'unknown').replace(/[^A-Za-z0-9_]/g, '').slice(0, 40);
1758
+ }
1759
+
1760
+ /**
1761
+ * D-VERIFY-STDOUT: settle whatever main() returned into what the boundary may
1762
+ * print. The code must be a known exit code and stdout must be empty or one of
1763
+ * the closed shapes for that code; anything else prints nothing and exits 4.
1764
+ *
1765
+ * @param {unknown} outcome
1766
+ * @returns {Outcome}
1767
+ */
1768
+ function settleOutcome(outcome) {
1769
+ const o = /** @type {any} */ (outcome);
1770
+ const failed = { code: EXIT_CODES.REMOTE_FAILURE, stdout: '' };
1771
+ if (o === null || typeof o !== 'object' || typeof o.stdout !== 'string') return failed;
1772
+ if (!Object.values(EXIT_CODES).includes(o.code)) return failed;
1773
+ if (o.code === EXIT_CODES.USAGE || o.stdout === '') return { code: o.code, stdout: '' };
1774
+ if (!o.stdout.endsWith('\n')) return failed;
1775
+ const text = o.stdout.slice(0, -1);
1776
+ const splice = SPLICE_LINE_RE.exec(text);
1777
+ if (splice !== null && splice.groups !== undefined) {
1778
+ const passing = splice.groups.outcome === 'ok' || splice.groups.outcome === 'resplice';
1779
+ return o.code === (passing ? EXIT_CODES.OK : EXIT_CODES.OUTPUT_GATE_REFUSED) ? { code: o.code, stdout: o.stdout } : failed;
1780
+ }
1781
+ const readback = READBACK_LINE_RE.exec(text);
1782
+ if (readback !== null && readback.groups !== undefined) {
1783
+ const passing = readback.groups.outcome === 'ok';
1784
+ return o.code === (passing ? EXIT_CODES.OK : EXIT_CODES.OUTPUT_GATE_REFUSED) ? { code: o.code, stdout: o.stdout } : failed;
1785
+ }
1786
+ if (o.code !== EXIT_CODES.OK) return failed;
1787
+ if (PE.parseEvidenceLine(text).ok) return { code: o.code, stdout: o.stdout };
1788
+ const block = PE.parseBlock(text);
1789
+ if (block.ok && block.value.kind === 'lines' && block.value.ticked.length === 0 && !text.includes('\r')) {
1790
+ return { code: o.code, stdout: o.stdout };
1791
+ }
1792
+ return failed;
1793
+ }
1794
+
1795
+ // ---------------------------------------------------------------------------
1796
+ // Top-level boundary — the ONLY stdout write and exitCode assignment
1797
+ // ---------------------------------------------------------------------------
1798
+
1799
+ if (require.main === module) {
1800
+ let outcome;
1801
+ try {
1802
+ outcome = main(process.argv);
1803
+ } catch (err) {
1804
+ process.stderr.write('verify-evidence: internal error (' + errorLabel(err) + ')\n');
1805
+ outcome = { code: EXIT_CODES.REMOTE_FAILURE, stdout: '' };
1806
+ }
1807
+ const settled = settleOutcome(outcome);
1808
+ if (settled.stdout !== '') process.stdout.write(settled.stdout);
1809
+ process.exitCode = settled.code;
1810
+ }
1811
+
1812
+ // ---------------------------------------------------------------------------
1813
+ // Exports — the unit tests are the consumers
1814
+ // ---------------------------------------------------------------------------
1815
+
1816
+ module.exports = Object.freeze({
1817
+ EXIT_CODES,
1818
+ CAPS,
1819
+ PR_FIELDS,
1820
+ main,
1821
+ settleOutcome,
1822
+ });