devflow-kit 2.4.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/README.md +86 -18
  3. package/dist/agents/git.md +824 -0
  4. package/dist/cli/commands/agents.js +6 -1
  5. package/dist/cli/commands/attribution-prompts.js +1 -1
  6. package/dist/cli/commands/compliance-prompts.js +1 -1
  7. package/dist/cli/commands/compliance.js +23 -1
  8. package/dist/cli/commands/init-seed.js +24 -26
  9. package/dist/cli/commands/init.js +502 -71
  10. package/dist/cli/commands/install-report.js +205 -0
  11. package/dist/cli/commands/knowledge/index.js +2 -2
  12. package/dist/cli/commands/knowledge/toggle.js +27 -37
  13. package/dist/cli/commands/learning.js +37 -30
  14. package/dist/cli/commands/memory.js +79 -69
  15. package/dist/cli/commands/prompt-io.js +4 -4
  16. package/dist/cli/commands/security.js +76 -16
  17. package/dist/cli/commands/skills.js +53 -7
  18. package/dist/cli/commands/tracker-prompts.js +145 -0
  19. package/dist/cli/commands/tracker.js +405 -0
  20. package/dist/cli/commands/uninstall.js +211 -65
  21. package/dist/cli.js +2 -0
  22. package/dist/commands/bug-analysis.md +22 -4
  23. package/dist/commands/code-review.md +44 -15
  24. package/dist/commands/debug.md +20 -6
  25. package/dist/commands/dynamic-build.md +289 -67
  26. package/dist/commands/dynamic-plan.md +60 -21
  27. package/dist/commands/dynamic-profile.md +1 -1
  28. package/dist/commands/dynamic-tickets.md +58 -8
  29. package/dist/commands/explore.md +2 -2
  30. package/dist/commands/implement.md +241 -53
  31. package/dist/commands/plan.md +88 -17
  32. package/dist/commands/release.md +64 -17
  33. package/dist/commands/resolve.md +138 -58
  34. package/dist/commands/self-review.md +2 -2
  35. package/dist/core/agent-models.js +55 -12
  36. package/dist/core/assets.js +58 -2
  37. package/dist/core/evidence-policy.js +147 -0
  38. package/dist/core/feature-config.js +130 -64
  39. package/dist/core/feature-switch.js +112 -0
  40. package/dist/core/flags.js +4 -4
  41. package/dist/core/manifest.js +33 -7
  42. package/dist/core/mds-variants.js +861 -0
  43. package/dist/core/model-discovery.js +12 -1
  44. package/dist/core/plugins.js +357 -9
  45. package/dist/core/project-paths.js +1 -1
  46. package/dist/core/proxy-log.js +8 -6
  47. package/dist/core/proxy-state.js +11 -8
  48. package/dist/core/reference-sweep.js +136 -0
  49. package/dist/core/tracker.js +407 -0
  50. package/dist/skills/git/references/decision-markers.md +19 -0
  51. package/dist/skills/git/references/learn-conventions.md +56 -0
  52. package/dist/skills/git/references/pr/check-ci-status.md +14 -0
  53. package/dist/skills/git/references/pr/check-merge-readiness.md +28 -0
  54. package/dist/skills/git/references/pr/ensure-pr-ready.md +24 -0
  55. package/dist/skills/git/references/pr/fetch-review-threads.md +22 -0
  56. package/dist/skills/git/references/pr/post-resolution-summary.md +40 -0
  57. package/dist/skills/git/references/pr/post-review-summary.md +42 -0
  58. package/dist/skills/git/references/pr/resolve-review-threads.md +35 -0
  59. package/dist/skills/git/references/pr/update-pr-evidence.md +14 -0
  60. package/dist/skills/git/references/pr/validate-branch.md +18 -0
  61. package/dist/skills/git/references/publication-gate.md +13 -0
  62. package/dist/skills/git/references/tracker/_mcp.md +153 -0
  63. package/dist/skills/git/references/tracker/github/associate-release.md +18 -0
  64. package/dist/skills/git/references/tracker/github/backlink-shipped-issues.md +40 -0
  65. package/dist/skills/git/references/tracker/github/create-release.md +11 -0
  66. package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +16 -0
  67. package/dist/skills/git/references/tracker/github/ensure-traceable-issue.md +69 -0
  68. package/dist/skills/git/references/tracker/github/fetch-issue.md +32 -0
  69. package/dist/skills/git/references/tracker/github/fetch-issues-batch.md +17 -0
  70. package/dist/skills/git/references/tracker/github/gather-release-evidence.md +19 -0
  71. package/dist/skills/git/references/tracker/github/manage-debt.md +101 -0
  72. package/dist/skills/git/references/tracker/github/post-wave-report.md +28 -0
  73. package/dist/skills/git/references/tracker/github/setup-task.md +26 -0
  74. package/dist/skills/git/references/tracker/jira/associate-release.md +18 -0
  75. package/dist/skills/git/references/tracker/jira/backlink-shipped-issues.md +49 -0
  76. package/dist/skills/git/references/tracker/jira/create-release.md +17 -0
  77. package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +22 -0
  78. package/dist/skills/git/references/tracker/jira/ensure-traceable-issue.md +53 -0
  79. package/dist/skills/git/references/tracker/jira/fetch-issue.md +14 -0
  80. package/dist/skills/git/references/tracker/jira/fetch-issues-batch.md +15 -0
  81. package/dist/skills/git/references/tracker/jira/gather-release-evidence.md +18 -0
  82. package/dist/skills/git/references/tracker/jira/manage-debt.md +37 -0
  83. package/dist/skills/git/references/tracker/jira/post-wave-report.md +33 -0
  84. package/dist/skills/git/references/tracker/jira/setup-task.md +31 -0
  85. package/dist/skills/git/references/tracker/linear/associate-release.md +18 -0
  86. package/dist/skills/git/references/tracker/linear/backlink-shipped-issues.md +53 -0
  87. package/dist/skills/git/references/tracker/linear/create-release.md +17 -0
  88. package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +22 -0
  89. package/dist/skills/git/references/tracker/linear/ensure-traceable-issue.md +53 -0
  90. package/dist/skills/git/references/tracker/linear/fetch-issue.md +14 -0
  91. package/dist/skills/git/references/tracker/linear/fetch-issues-batch.md +15 -0
  92. package/dist/skills/git/references/tracker/linear/gather-release-evidence.md +18 -0
  93. package/dist/skills/git/references/tracker/linear/manage-debt.md +37 -0
  94. package/dist/skills/git/references/tracker/linear/post-wave-report.md +33 -0
  95. package/dist/skills/git/references/tracker/linear/setup-task.md +32 -0
  96. package/dist/skills/git/references/trust-rule.md +7 -0
  97. package/dist/targets/claude-code/installer.js +1213 -31
  98. package/dist/targets/claude-code/legacy.js +5 -0
  99. package/dist/targets/claude-code/post-install.js +196 -74
  100. package/dist/targets/claude-code/tracker-install.js +161 -0
  101. package/package.json +4 -3
  102. package/src/assets/agents/code.md +42 -4
  103. package/src/assets/agents/design.md +1 -1
  104. package/src/assets/agents/git.mds +827 -0
  105. package/src/assets/agents/knowledge.md +1 -1
  106. package/src/assets/agents/learning.md +11 -0
  107. package/src/assets/agents/synthesize.md +1 -1
  108. package/src/assets/agents/test.md +16 -5
  109. package/src/assets/agents/tracker.md +467 -0
  110. package/src/assets/agents/validate.md +7 -5
  111. package/src/assets/commands/_partials/_engine.mds +11 -9
  112. package/src/assets/commands/_partials/_evidence_policy.mds +30 -0
  113. package/src/assets/commands/_partials/_knowledge.mds +2 -2
  114. package/src/assets/commands/_partials/_plan_contract.mds +22 -7
  115. package/src/assets/commands/_partials/_preamble.mds +1 -1
  116. package/src/assets/commands/_partials/_publication.mds +3 -1
  117. package/src/assets/commands/_partials/_ticket_template.mds +3 -2
  118. package/src/assets/commands/_partials/_tracker.mds +18 -0
  119. package/src/assets/commands/_partials/_wave.mds +16 -10
  120. package/src/assets/commands/bug-analysis.mds +15 -5
  121. package/src/assets/commands/code-review.mds +34 -14
  122. package/src/assets/commands/debug.mds +11 -4
  123. package/src/assets/commands/dynamic-build.mds +227 -41
  124. package/src/assets/commands/dynamic-plan.mds +35 -13
  125. package/src/assets/commands/dynamic-tickets.mds +47 -5
  126. package/src/assets/commands/implement.mds +206 -52
  127. package/src/assets/commands/plan.mds +70 -17
  128. package/src/assets/commands/release.md +64 -17
  129. package/src/assets/commands/resolve.mds +126 -56
  130. package/src/assets/mds/git/_pr.mds +331 -0
  131. package/src/assets/mds/git/_references.mds +135 -0
  132. package/src/assets/mds/tracker/_common.mds +156 -0
  133. package/src/assets/mds/tracker/_github.mds +472 -0
  134. package/src/assets/mds/tracker/_jira.mds +407 -0
  135. package/src/assets/mds/tracker/_linear.mds +449 -0
  136. package/src/assets/mds/tracker/_mcp.mds +299 -0
  137. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +5 -8
  138. package/src/assets/scripts/hooks/background-memory-update +14 -9
  139. package/src/assets/scripts/hooks/capture-prompt +6 -2
  140. package/src/assets/scripts/hooks/capture-question +6 -2
  141. package/src/assets/scripts/hooks/capture-turn +6 -2
  142. package/src/assets/scripts/hooks/ensure-devflow-init +1 -1
  143. package/src/assets/scripts/hooks/ensure-root-gitignore +161 -60
  144. package/src/assets/scripts/hooks/hook-log-init +3 -1
  145. package/src/assets/scripts/hooks/json-helper.cjs +223 -5
  146. package/src/assets/scripts/hooks/lib/project-paths.cjs +1 -1
  147. package/src/assets/scripts/hooks/memory-worker +15 -8
  148. package/src/assets/scripts/hooks/pre-compact-memory +12 -8
  149. package/src/assets/scripts/hooks/preamble +1 -4
  150. package/src/assets/scripts/hooks/queue-append +68 -24
  151. package/src/assets/scripts/hooks/session-start-context +355 -8
  152. package/src/assets/scripts/hooks/session-start-memory +12 -8
  153. package/src/assets/scripts/pr-evidence.cjs +1961 -0
  154. package/src/assets/scripts/redact-secrets.cjs +490 -62
  155. package/src/assets/scripts/release-trace.cjs +1143 -0
  156. package/src/assets/scripts/resolve-evidence-policy.cjs +1065 -0
  157. package/src/assets/scripts/verify-evidence.cjs +1822 -0
  158. package/src/assets/skills/compliance/SKILL.md +2 -0
  159. package/src/assets/skills/docs-framework/SKILL.md +5 -3
  160. package/src/assets/skills/git/SKILL.md +8 -78
  161. package/src/assets/skills/git/references/github-api.md +179 -141
  162. package/src/assets/skills/git/references/patterns.md +11 -6
  163. package/src/assets/skills/review-methodology/SKILL.md +1 -1
  164. package/src/assets/skills/review-methodology/references/patterns.md +6 -61
  165. package/src/assets/skills/review-methodology/references/violations.md +14 -22
  166. package/src/assets/agents/git.md +0 -938
@@ -0,0 +1,1961 @@
1
+ // src/assets/scripts/pr-evidence.cjs
2
+ //
3
+ // The PURE core of PR test-plan evidence: the TP-line, claim, exception,
4
+ // EVIDENCE-line and wave-block grammars, the marker literals, the state ladder,
5
+ // rendering, the CRLF-aware body splice, the tally, the link check and the one
6
+ // implementation of the trust rule. Installed as a top-level sibling of redact-secrets.cjs under
7
+ // ~/.devflow/scripts/ and required by verify-evidence.cjs, which performs every
8
+ // read and every write; this module has no command line of its own.
9
+ //
10
+ // Design constraints (binding):
11
+ // - D-EVIDENCE-PURE: nothing is required and `process` is never named. No file,
12
+ // network, subprocess, clock or environment is reached; the one hash this
13
+ // module needs (sha256) is injected by the caller. A source guard and a
14
+ // sandboxed load in the test suite hold this.
15
+ // - Every fallible function returns a Result — {ok: true, value} or {ok: false,
16
+ // error: {code, line}} — and never throws. An error carries a code from
17
+ // ERROR_CODES and a 1-based line number only, never a byte of its input, so a
18
+ // caller can print it without echoing hostile text.
19
+ // - Every loop is bounded by an input already capped in LIMITS.
20
+ // - Every exported RegExp is frozen and non-global, so no caller can share or
21
+ // corrupt lastIndex state.
22
+
23
+ 'use strict';
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Closed vocabularies
27
+ // ---------------------------------------------------------------------------
28
+
29
+ /** @typedef {'VERIFIED-CI' | 'ATTESTED-LOCAL' | 'UNVERIFIED' | 'STALE' | 'FAILED' | 'INDETERMINATE'} State */
30
+ /** @typedef {'ci' | 'local' | 'manual'} Method */
31
+ /** @typedef {'PASS' | 'FAIL' | 'SKIP'} Outcome */
32
+ /** @typedef {'ticket-link' | 'test-plan'} ExceptionKind */
33
+ /** @typedef {'PASS' | 'UNVERIFIED' | 'QUARANTINED' | 'BLOCKED'} WaveVerdict */
34
+ /**
35
+ * @typedef {'oversize' | 'malformed' | 'empty' | 'order' | 'duplicate' | 'mismatch' | 'invalid'
36
+ * | 'orphan' | 'unlinked' | 'unmerged'} ErrorCode
37
+ * The last three are the wave block's cross rules (see parseWaveBlock).
38
+ */
39
+
40
+ /** The six TP states, closed. The contract's `States (closed)` list is pinned to this order. */
41
+ const STATES = Object.freeze(/** @type {State[]} */ ([
42
+ 'VERIFIED-CI', 'ATTESTED-LOCAL', 'UNVERIFIED', 'STALE', 'FAILED', 'INDETERMINATE',
43
+ ]));
44
+
45
+ /** The only states that count as verified. */
46
+ const VERIFIED_STATES = Object.freeze(/** @type {State[]} */ (['VERIFIED-CI', 'ATTESTED-LOCAL']));
47
+
48
+ /** How a TP is verified: the CI suite, a local command's exit code, or observed manual steps. */
49
+ const METHODS = Object.freeze(/** @type {Method[]} */ (['ci', 'local', 'manual']));
50
+
51
+ /** Every evidence-exception kind, in the order the EVIDENCE line lists them. */
52
+ const EXCEPTION_KINDS = Object.freeze(/** @type {ExceptionKind[]} */ (['ticket-link', 'test-plan']));
53
+
54
+ /**
55
+ * D-CAPS: every bound, by name.
56
+ * TP_MAX / AC_MAX highest TP number and AC number a line may cite
57
+ * SCENARIO_MAX scenario length, in code points
58
+ * GLOB_MAX / GLOBS_PER_LINE one glob's length; globs on one TP line
59
+ * CLAIM_LINES claim lines read — the LAST ones (append-only, newest wins)
60
+ * COMMENT_LINES lines read from an evidence comment
61
+ * BODY_CHARS a PR body after the splice; over it the block becomes counts only
62
+ * FULL_COMMENT_CHARS a FULL evidence comment; over it the comment is a STUB
63
+ * INPUT_CHARS any text a parser accepts at all
64
+ * PATH_CHARS / DIFF_FILES a diff path, and the diff paths, a glob is matched against
65
+ * RUNS_PER_SHA runs at one SHA (`gh run list --limit 20`)
66
+ * TRUST_LOOKUPS permission lookups per spawn
67
+ * WAVE_BLOCK_CHARS a whole wave block
68
+ * WAVE_ROWS a wave block's table rows, and its related lines (at most one per row,
69
+ * plus the one tracking line — see D-WAVE-TRACKING)
70
+ */
71
+ const LIMITS = Object.freeze({
72
+ TP_MAX: 200,
73
+ AC_MAX: 999,
74
+ SCENARIO_MAX: 200,
75
+ GLOB_MAX: 120,
76
+ GLOBS_PER_LINE: 10,
77
+ CLAIM_LINES: 400,
78
+ COMMENT_LINES: 1000,
79
+ BODY_CHARS: 60000,
80
+ FULL_COMMENT_CHARS: 55000,
81
+ INPUT_CHARS: 1048576,
82
+ PATH_CHARS: 4096,
83
+ DIFF_FILES: 5000,
84
+ RUNS_PER_SHA: 20,
85
+ TRUST_LOOKUPS: 20,
86
+ WAVE_BLOCK_CHARS: 16000,
87
+ WAVE_ROWS: 100,
88
+ });
89
+
90
+ /**
91
+ * D-MARKERS: the ONLY home of the marker literals. Every other site builds a
92
+ * marker from these fields, and a source guard holds that no literal appears
93
+ * outside this object. The block markers bracket the PR body's test-plan block;
94
+ * the evidence marker is the first line of an evidence comment.
95
+ */
96
+ const MARKERS = Object.freeze({
97
+ BLOCK_START: '<!-- devflow:test-plan -->',
98
+ BLOCK_END: '<!-- /devflow:test-plan -->',
99
+ EVIDENCE_OPEN: '<!-- devflow:evidence',
100
+ EVIDENCE_RE: Object.freeze(/^<!-- devflow:evidence head:(?<head>[0-9a-f]{40}) key:(?<key>[0-9a-f]{12}) -->$/),
101
+ });
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Grammars
105
+ // ---------------------------------------------------------------------------
106
+
107
+ /** A git object name as this module accepts one: 7–40 lowercase hex, so never an option. */
108
+ const SHA_RE = Object.freeze(/^[0-9a-f]{7,40}$/);
109
+
110
+ /** A full 40-hex SHA. */
111
+ const SHA40_RE = /^[0-9a-f]{40}$/;
112
+
113
+ /** One `files:` glob: the class and bound the contract states. */
114
+ const GLOB_RE = Object.freeze(/^[A-Za-z0-9._/*?-]{1,120}$/);
115
+
116
+ /** A GitHub login: an alphanumeric, then up to 38 alphanumerics or hyphens. */
117
+ const LOGIN_RE = Object.freeze(/^[A-Za-z0-9][A-Za-z0-9-]{0,38}$/);
118
+
119
+ /**
120
+ * D-TP-LINE: one test-plan line, as `_plan_contract.mds` define `test_plan_line()`
121
+ * states it (parity-pinned by tests/evidence/contract-parity.test.ts):
122
+ * - [ ] TP-<n> (AC-<m>) <scenario> — method:<ci|local|manual>[ [files: <glob>[, <glob>…]]]
123
+ * <n> 1–200, <m> 1–999, no leading zeros. The scenario is 1–200 printable code
124
+ * points (no Unicode control, format, surrogate, private-use, unassigned or
125
+ * line/paragraph-separator character) with no leading or trailing space, no `<`,
126
+ * `>`, backtick, `[`, `]`, `#`, `@` or `/`, and never the text ` — method:`. The
127
+ * per-character lookahead keeps the scan linear.
128
+ *
129
+ * D-TP-SCENARIO: the scenario is pasted into the PR body, so it admits none of
130
+ * the characters an issue reference (`#12`, `o/r#12`, a full issue URL), a closing
131
+ * keyword's target, an @-mention, markup or a code span needs — the same exclusions
132
+ * the exception reason carries. A path belongs in `files:`, whose glob class keeps
133
+ * `/` and admits no `#` or `@`.
134
+ */
135
+ const TP_LINE_RE = Object.freeze(/^- \[ \] TP-(?<n>200|1[0-9]{2}|[1-9][0-9]?) \(AC-(?<ac>[1-9][0-9]{0,2})\) (?<scenario>(?! )(?:(?! — method:)[^\p{C}\p{Zl}\p{Zp}<>`[\]#@\/]){1,200}(?<! )) — method:(?<method>ci|local|manual)(?: \[files: (?<files>[A-Za-z0-9._/*?-]{1,120}(?:, [A-Za-z0-9._/*?-]{1,120}){0,9})\])?$/u);
136
+
137
+ /**
138
+ * One claim line of the evidence file's `## Claims` section:
139
+ * - (TP-<n>|gate:(validate|qa)) (PASS|FAIL|SKIP) sha:<40-hex> by:(test|validate)[ exit:<0-255>]
140
+ */
141
+ const CLAIM_LINE_RE = Object.freeze(/^- (?<target>TP-(?<tp>200|1[0-9]{2}|[1-9][0-9]?)|gate:(?<gate>validate|qa)) (?<outcome>PASS|FAIL|SKIP) sha:(?<sha>[0-9a-f]{40}) by:(?<by>test|validate)(?: exit:(?<exit>25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9]))?$/);
142
+
143
+ /**
144
+ * One evidence-exception line. Identical to the Code agent's paste gate for
145
+ * `PR_EXCEPTIONS` except for the kind set (parity-pinned), so no capture is named:
146
+ * the source must stay byte-comparable with that gate.
147
+ */
148
+ const EXCEPTION_LINE_RE = Object.freeze(/^- `(ticket-link|test-plan)` self-attested by (@[A-Za-z0-9][A-Za-z0-9-]{0,38}|\(login unavailable\)) at [0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z: [!"%'()*+,.0-9:;=?A-Z^_a-z{|}~-][ !"%'()*+,.0-9:;=?A-Z^_a-z{|}~-]{0,199}$/);
149
+
150
+ /**
151
+ * D-EVIDENCE-LINE: the one line verify-evidence.cjs prints on stdout. Every field
152
+ * is a closed-vocabulary token or a bounded number, so no byte of a PR body or
153
+ * comment can ride on it. Consistency the pattern cannot express (the counts sum
154
+ * to total; `stale:` lists exactly STALE ascending ids) is held by
155
+ * formatEvidenceLine and parseEvidenceLine.
156
+ */
157
+ const EVIDENCE_LINE_RE = Object.freeze(/^EVIDENCE pr:(?<pr>[1-9][0-9]{0,9}) head:(?<head>[0-9a-f]{40}) total:(?<total>0|200|1[0-9]{2}|[1-9][0-9]?) VERIFIED-CI:(?<verifiedCi>0|200|1[0-9]{2}|[1-9][0-9]?) ATTESTED-LOCAL:(?<attestedLocal>0|200|1[0-9]{2}|[1-9][0-9]?) UNVERIFIED:(?<unverified>0|200|1[0-9]{2}|[1-9][0-9]?) STALE:(?<staleCount>0|200|1[0-9]{2}|[1-9][0-9]?) FAILED:(?<failed>0|200|1[0-9]{2}|[1-9][0-9]?) INDETERMINATE:(?<indeterminate>0|200|1[0-9]{2}|[1-9][0-9]?) stale:(?<stale>none|TP-(?:200|1[0-9]{2}|[1-9][0-9]?)(?:,TP-(?:200|1[0-9]{2}|[1-9][0-9]?)){0,199}) exceptions:(?<exceptions>none|ticket-link(?:,test-plan)?|test-plan) approval:(?<approval>yes|no|unchecked) key:(?<key>[0-9a-f]{12}) posted:(?<posted>yes|no|n\/a) body:(?<body>same|changed)$/);
158
+
159
+ /**
160
+ * One TP record of an evidence comment:
161
+ * - TP-n STATE sha:<40|none> out:<PASS|FAIL|SKIP|none>[ run:<id>/<n>| run:none][ exit:<k>] h:<12hex>
162
+ * `sha:`, `out:` and `exit:` are the claim the verdict rests on; RECORD_STATES
163
+ * holds which states each `out:` may carry.
164
+ */
165
+ const TP_RECORD_RE = /^- TP-(?<id>200|1[0-9]{2}|[1-9][0-9]?) (?<state>VERIFIED-CI|ATTESTED-LOCAL|UNVERIFIED|STALE|FAILED|INDETERMINATE) sha:(?<sha>[0-9a-f]{40}|none) out:(?<outcome>PASS|FAIL|SKIP|none)(?: run:(?:(?<runId>[1-9][0-9]{0,14})\/(?<attempt>[1-9][0-9]{0,2})|(?<noRuns>none)))?(?: exit:(?<exit>25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9]))? h:(?<hash>[0-9a-f]{12})$/;
166
+
167
+ /** One exception record of an evidence comment. */
168
+ const EXCEPTION_RECORD_RE = /^- exception:(?<kind>ticket-link|test-plan) by:(?:@(?<login>[A-Za-z0-9][A-Za-z0-9-]{0,38})|unavailable) at:(?<at>[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z) status:self-attested$/;
169
+
170
+ /** An exception reason as the exception grammar admits it. */
171
+ const REASON_RE = /^[!"%'()*+,.0-9:;=?A-Z^_a-z{|}~-][ !"%'()*+,.0-9:;=?A-Z^_a-z{|}~-]{0,199}$/;
172
+
173
+ /** A UTC timestamp as the exception grammar admits it. */
174
+ const UTC_RE = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$/;
175
+
176
+ /** The counts-only block line (see tallyLine). */
177
+ const COUNTS_LINE_RE = /^Verified (?<verified>0|200|1[0-9]{2}|[1-9][0-9]?)\/(?<total>0|200|1[0-9]{2}|[1-9][0-9]?): VERIFIED-CI (?:0|200|1[0-9]{2}|[1-9][0-9]?), ATTESTED-LOCAL (?:0|200|1[0-9]{2}|[1-9][0-9]?), UNVERIFIED (?:0|200|1[0-9]{2}|[1-9][0-9]?), STALE (?:0|200|1[0-9]{2}|[1-9][0-9]?), FAILED (?:0|200|1[0-9]{2}|[1-9][0-9]?), INDETERMINATE (?:0|200|1[0-9]{2}|[1-9][0-9]?) \(counts only: the TP lines exceed the PR body limit\)$/;
178
+
179
+ /**
180
+ * D-LINK: a repository html_url — https only, a lowercase host (optionally a
181
+ * port), an owner login and a repository name that is not `.` or `..`. No
182
+ * userinfo, query, fragment, trailing slash or percent-escape can match.
183
+ */
184
+ const HTML_URL_RE = /^https:\/\/[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*(?::[1-9][0-9]{0,4})?\/[A-Za-z0-9][A-Za-z0-9-]{0,38}\/(?!\.{1,2}$)[A-Za-z0-9._-]{1,100}$/;
185
+
186
+ /**
187
+ * D-LINK: the only paths a link may add under html_url — a workflow run (and one
188
+ * attempt of it), a commit, or a PR's head ref. `refs/pull/N/merge`, leading
189
+ * zeros, `..` and every percent-escape are outside the grammar.
190
+ */
191
+ const LINK_SUFFIX_RE = /^(?:actions\/runs\/[1-9][0-9]{0,14}(?:\/attempts\/[1-9][0-9]{0,2})?|commit\/[0-9a-f]{40}|tree\/refs\/pull\/[1-9][0-9]{0,9}\/head)$/;
192
+
193
+ /** The longest html_url or link considered at all. */
194
+ const MAX_URL_CHARS = 400;
195
+
196
+ /** The largest run id a record, a link or a run fact may carry (15 digits: exact as a JS number). */
197
+ const MAX_RUN_ID = 999999999999999;
198
+
199
+ /** The most actors permissionLookups reads — far above any real PR's comment and review count. */
200
+ const MAX_ACTORS = 10000;
201
+
202
+ const PLAN_HEADING = '## Test Plan';
203
+ const CLAIMS_HEADING = '## Claims';
204
+ const EXCEPTIONS_HEADING = '## Evidence Exceptions';
205
+ const EVIDENCE_HEADING = '## Test Plan Evidence';
206
+ const EM_DASH = '—';
207
+ const UNTICKED = '- [ ] ';
208
+ const TICKED = '- [x] ';
209
+
210
+ /**
211
+ * D-WAVE: the wave block — the wave PR body's `## Related Issues` section and its
212
+ * evidence table, rendered by /devflow:dynamic-build and admitted only through
213
+ * `verify-evidence.cjs check wave`:
214
+ *
215
+ * ## Related Issues
216
+ * Refs #9
217
+ * Closes #12
218
+ * Refs #13
219
+ *
220
+ * ## Wave Evidence
221
+ * | T | Ticket | Verdict | Evaluate | Test | Surviving | Coverage |
222
+ * |---|---|---|---|---|---|---|
223
+ * | T1 | #12 | PASS | PASS | PASS | 0 | complete |
224
+ * | T2 | #13 | QUARANTINED | FAIL-FIXED | SKIPPED | 2 | incomplete |
225
+ *
226
+ * Every cell is a closed-vocabulary token or a bounded number, and every related
227
+ * line is a shape-gated reference, so no byte of a ticket title, a finding or an
228
+ * agent's prose can ride into the PR body on it. The two headings, in order; an
229
+ * admitted block IS the body's `## Related Issues` section. `Refs #9` above is the
230
+ * optional tracking line (D-WAVE-TRACKING).
231
+ */
232
+ const WAVE_HEADINGS = Object.freeze(['## Related Issues', '## Wave Evidence']);
233
+
234
+ /** The evidence table's header line, verbatim. */
235
+ const WAVE_TABLE_HEADER = '| T | Ticket | Verdict | Evaluate | Test | Surviving | Coverage |';
236
+
237
+ /** The separator under it: one bare `---` per column, no alignment colon. */
238
+ const WAVE_TABLE_SEPARATOR = '|---|---|---|---|---|---|---|';
239
+
240
+ /**
241
+ * A wave row's verdict: merged as PASS; merged as UNVERIFIED (a FAIL-FIXED Gate 2,
242
+ * fixes applied and not re-run); QUARANTINED (it ran and did not merge, or its
243
+ * merge was quarantined after a red build); BLOCKED (it never ran).
244
+ */
245
+ const WAVE_VERDICTS = Object.freeze(/** @type {WaveVerdict[]} */ (['PASS', 'UNVERIFIED', 'QUARANTINED', 'BLOCKED']));
246
+
247
+ /** The verdicts of a ticket that merged — the only rows a `Closes` line may name. */
248
+ const WAVE_MERGED_VERDICTS = Object.freeze(/** @type {WaveVerdict[]} */ (['PASS', 'UNVERIFIED']));
249
+
250
+ /** An Evaluate or Test cell: a Gate 2 verdict, or `—` where the gate never ran. */
251
+ const WAVE_GATE_VALUES = Object.freeze(['PASS', 'FAIL', 'FAIL-FIXED', 'SKIPPED', EM_DASH]);
252
+
253
+ /** A Coverage cell: whether the review pass covered every focus. */
254
+ const WAVE_COVERAGE_VALUES = Object.freeze(['complete', 'incomplete', EM_DASH]);
255
+
256
+ /** A row's first cell, `T1` … `T100` (the sequence itself is checked by the parser). */
257
+ const WAVE_T_RE = /^T(?:100|[1-9][0-9]?)$/;
258
+
259
+ /** A Surviving cell: the review pass's surviving-finding count, or `—`. */
260
+ const WAVE_SURVIVING_RE = /^(?:[0-9]{1,3}|—)$/;
261
+
262
+ /**
263
+ * D-WAVE-REF: one related line of a wave block. `Closes` takes only a GitHub `#N`
264
+ * — Jira and Linear render `Refs` — and `Refs` takes `#N` (an unmerged GitHub
265
+ * ticket: its captured `Closes` line with the keyword swapped) or a keyed ref
266
+ * whose key is the union of the Jira `[A-Z][A-Z0-9_]{1,9}` and the Linear
267
+ * `[A-Z][A-Z0-9]{0,9}` key grammars. It admits every line code.md's R7 paste gate
268
+ * admits, their Closes→Refs swap, and nothing else (parity-pinned by
269
+ * tests/evidence/wave-block.test.ts).
270
+ */
271
+ const RELATED_LINE_RE = Object.freeze(/^(?:Closes (?<closes>#[1-9][0-9]{0,8})|Refs (?<refs>#[1-9][0-9]{0,8}|[A-Z][A-Z0-9_]{0,9}-[1-9][0-9]{0,8}))$/);
272
+
273
+ /** A wave row's Ticket cell: a reference of RELATED_LINE_RE's shapes, or `(none)`. */
274
+ const WAVE_TICKET_RE = Object.freeze(/^(?:#[1-9][0-9]{0,8}|[A-Z][A-Z0-9_]{0,9}-[1-9][0-9]{0,8}|\(none\))$/);
275
+
276
+ /** The Ticket cell of a row with no reference. */
277
+ const NO_TICKET = '(none)';
278
+
279
+ /** The three evidence-file sections, by heading. A Map, so no prototype key can match. */
280
+ const SECTION_KEYS = new Map([
281
+ [PLAN_HEADING, 'testPlan'],
282
+ [CLAIMS_HEADING, 'claims'],
283
+ [EXCEPTIONS_HEADING, 'exceptions'],
284
+ ]);
285
+
286
+ /** Run conclusions by the ladder arm they feed. Anything outside all three is unclassifiable. */
287
+ const PASSING_CONCLUSIONS = new Set(['success', 'skipped', 'neutral']);
288
+ const FAILING_CONCLUSIONS = new Set(['failure', 'timed_out', 'startup_failure']);
289
+ const UNSETTLED_CONCLUSIONS = new Set(['cancelled', 'action_required', 'stale']);
290
+
291
+ /** A claim's outcome, as CLAIM_LINE_RE admits it. */
292
+ const CLAIM_OUTCOMES = Object.freeze(/** @type {Outcome[]} */ (['PASS', 'FAIL', 'SKIP']));
293
+
294
+ /**
295
+ * D-RECORD-OUTCOME: a record carries the claim's outcome (`out:`) beside the
296
+ * verdict, because the verdict alone cannot tell it — an INDETERMINATE, STALE or
297
+ * UNVERIFIED record may rest on a PASS or a FAIL. With the outcome, a later
298
+ * refresh re-derives the state from the facts as they are then (a pending run now
299
+ * green ⇒ VERIFIED-CI) instead of needing a new claim.
300
+ *
301
+ * The states each outcome may carry — exactly what the ladder can produce: a
302
+ * verified state needs a PASS; a FAIL can be anything but verified; a SKIP is
303
+ * decided by the first arm, so it is only ever UNVERIFIED; no claim (null, printed
304
+ * `out:none` beside `sha:none`) is UNVERIFIED, or INDETERMINATE when the record
305
+ * that might have held the claim could not be read. A record outside this table
306
+ * is refused — `malformed` by the parser, `invalid` by render and dedupeKey — so
307
+ * no contradictory record is ever printed or read back.
308
+ *
309
+ * @type {ReadonlyMap<Outcome | null, readonly State[]>}
310
+ */
311
+ const RECORD_STATES = new Map(/** @type {Array<[Outcome | null, readonly State[]]>} */ ([
312
+ ['PASS', STATES],
313
+ ['FAIL', Object.freeze(/** @type {State[]} */ (['UNVERIFIED', 'INDETERMINATE', 'STALE', 'FAILED']))],
314
+ ['SKIP', Object.freeze(/** @type {State[]} */ (['UNVERIFIED']))],
315
+ [null, Object.freeze(/** @type {State[]} */ (['UNVERIFIED', 'INDETERMINATE']))],
316
+ ]));
317
+
318
+ /** D-TRUST: the associations the association arm considers, and the permissions that satisfy it. */
319
+ const TRUSTED_ASSOCIATIONS = Object.freeze(['OWNER', 'MEMBER', 'COLLABORATOR']);
320
+ const TRUSTED_PERMISSIONS = Object.freeze(['admin', 'write']);
321
+
322
+ // ---------------------------------------------------------------------------
323
+ // Types
324
+ // ---------------------------------------------------------------------------
325
+
326
+ /**
327
+ * @template T
328
+ * @typedef {{ ok: true, value: T } | { ok: false, error: { code: ErrorCode, line: number } }} Result
329
+ *
330
+ * @typedef {{ id: number, ac: number, scenario: string, method: Method, files: readonly string[], line: string }} Tp
331
+ * `line` is the canonical (unticked) text — the text hashed for `h:`.
332
+ * @typedef {{ tps: readonly Tp[] }} Plan
333
+ *
334
+ * @typedef {{ target: string, tp: number | null, gate: 'validate' | 'qa' | null,
335
+ * outcome: Outcome, sha: string, by: 'test' | 'validate', exit: number | null }} Claim
336
+ * @typedef {{ tp: Map<number, Claim>, gates: { validate: Claim | null, qa: Claim | null },
337
+ * malformed: number, truncated: boolean }} Claims
338
+ *
339
+ * @typedef {{ id: number, attempt: number, status?: string, conclusion?: string | null,
340
+ * headSha?: string, url?: string, expired?: boolean }} Run
341
+ * The LATEST attempt of one run, as `gh run view <id> --attempt <n>` reported it,
342
+ * or `{id, attempt, expired: true}` for a 404 (runs expire after ~90 days).
343
+ *
344
+ * @typedef {{ claim: Claim | null, head: string | null, inPr: boolean | null,
345
+ * textMatches?: boolean, diff?: readonly string[] | null, verifyingSha?: string,
346
+ * runs?: readonly Run[] | null, htmlUrl?: string }} Facts
347
+ * Everything `classify` knows about one TP, gathered by the caller:
348
+ * claim the last valid claim for this TP, or null
349
+ * head the PR head, 40-hex; null when it could not be resolved
350
+ * inPr the claim SHA is an ancestor-or-equal of head AND not an
351
+ * ancestor-or-equal of merge-base(head, base); null = unresolved
352
+ * textMatches refresh mode: the TP text hash equals the trusted record's
353
+ * (omit outside refresh mode)
354
+ * diff `git diff --no-renames --name-only <claim> <head> --`, paths
355
+ * unquoted; needed when claim ≠ head and the TP has files;
356
+ * null = unresolved
357
+ * verifyingSha ci TPs: the SHA the runs were listed at — the claim SHA
358
+ * (default) or head; any other SHA is refused
359
+ * runs ci TPs: the latest attempt of every run at verifyingSha;
360
+ * null or absent = unresolved (error, timeout, cap, rate limit)
361
+ * htmlUrl ci TPs: the repository's html_url
362
+ *
363
+ * @typedef {{ id: number, attempt: number } | 'none' | null} RunRef
364
+ * The run a verdict rests on; 'none' labels a ci PASS whose SHA has no runs at all.
365
+ * @typedef {{ state: State, sha: string | null, outcome: Outcome | null, run: RunRef, exit: number | null }} Verdict
366
+ * `sha`, `outcome` and `exit` are the valid claim's (all null without one).
367
+ * @typedef {{ id: number, state: State, sha: string | null, outcome: Outcome | null, run: RunRef,
368
+ * exit: number | null, hash: string }} TpRecord
369
+ * One record line; `outcome` null prints `out:none` (see D-RECORD-OUTCOME).
370
+ * @typedef {{ kind: ExceptionKind, login: string | null, at: string, reason: string | null }} ExceptionRecord
371
+ * `reason` is null when read back from an evidence comment (reasons are not records).
372
+ * @typedef {{ head: string, key: string, htmlUrl: string, records: readonly TpRecord[],
373
+ * exceptions: readonly ExceptionRecord[] }} Evidence
374
+ * @typedef {{ total: number, verified: number, counts: Readonly<Record<State, number>> }} Tally
375
+ *
376
+ * @typedef {{ login: string, association: string }} Actor
377
+ * @typedef {{ viewer: string, prAuthor: string, isCrossRepository: boolean | undefined,
378
+ * permissions?: { get(login: string): string | null | undefined, size: number } }} TrustContext
379
+ * `permissions` is a Map from each looked-up login to what the permission API
380
+ * printed (null for a 404 or an error). A login absent from it was never looked up.
381
+ *
382
+ * @typedef {{ pr: number, head: string, total: number, counts: Record<State, number>,
383
+ * stale: readonly number[], exceptions: readonly ExceptionKind[],
384
+ * approval: 'yes' | 'no' | 'unchecked', key: string, posted: 'yes' | 'no' | 'n/a',
385
+ * body: 'same' | 'changed' }} EvidenceFields
386
+ *
387
+ * @typedef {{ keyword: 'Closes' | 'Refs', ref: string, line: number }} WaveRelated
388
+ * One related line; `line` is its 1-based line in the block.
389
+ * @typedef {{ k: number, ticket: string | null, verdict: WaveVerdict, evaluate: string, test: string,
390
+ * surviving: number | null, coverage: string, line: number }} WaveRow
391
+ * One table row; `ticket` is null for `(none)`, `surviving` for `—`.
392
+ * @typedef {{ tracking: WaveRelated | null, related: readonly WaveRelated[], rows: readonly WaveRow[] }} WaveBlock
393
+ * `related` holds the lines that name a row; `tracking` is the leading `Refs`
394
+ * line that names none (D-WAVE-TRACKING), or null.
395
+ */
396
+
397
+ // ---------------------------------------------------------------------------
398
+ // Small helpers
399
+ // ---------------------------------------------------------------------------
400
+
401
+ /**
402
+ * @template T
403
+ * @param {T} value
404
+ * @returns {Result<T>}
405
+ */
406
+ function ok(value) {
407
+ return Object.freeze({ ok: /** @type {true} */ (true), value });
408
+ }
409
+
410
+ /**
411
+ * @param {ErrorCode} code
412
+ * @param {number} [line]
413
+ * @returns {{ ok: false, error: { code: ErrorCode, line: number } }}
414
+ */
415
+ function fail(code, line) {
416
+ return Object.freeze({ ok: /** @type {false} */ (false), error: Object.freeze({ code, line: line || 0 }) });
417
+ }
418
+
419
+ /** @param {unknown} v @returns {v is object} */
420
+ function isObject(v) {
421
+ return typeof v === 'object' && v !== null;
422
+ }
423
+
424
+ /** @param {unknown} v @returns {boolean} */
425
+ function isSha40(v) {
426
+ return typeof v === 'string' && SHA40_RE.test(v);
427
+ }
428
+
429
+ /** @param {unknown} v @param {number} lo @param {number} hi @returns {boolean} */
430
+ function isIntIn(v, lo, hi) {
431
+ return typeof v === 'number' && Number.isInteger(v) && v >= lo && v <= hi;
432
+ }
433
+
434
+ /**
435
+ * Split text into lines for the parsers: `\n` ends a line and one `\r` before it
436
+ * is part of that ending; any other `\r` stays in the line, where every grammar
437
+ * refuses it. A final empty line from a trailing newline is dropped.
438
+ *
439
+ * @param {string} text
440
+ * @returns {string[]}
441
+ */
442
+ function textLines(text) {
443
+ const rows = text.split('\n');
444
+ for (let i = 0; i < rows.length; i++) {
445
+ if (rows[i].endsWith('\r')) rows[i] = rows[i].slice(0, -1);
446
+ }
447
+ if (rows.length > 0 && rows[rows.length - 1] === '' && text.endsWith('\n')) rows.pop();
448
+ return rows;
449
+ }
450
+
451
+ /**
452
+ * The line ending a block takes in `body`: CRLF when the body's first line break
453
+ * is CRLF — or, with no line break at all, when the body ends in a lone `\r`, so
454
+ * that appending keeps the result's first break CRLF too — otherwise LF.
455
+ *
456
+ * @param {string} body
457
+ * @returns {string}
458
+ */
459
+ function detectEol(body) {
460
+ const nl = body.indexOf('\n');
461
+ if (nl === -1) return body.endsWith('\r') ? '\r\n' : '\n';
462
+ return nl > 0 && body.charCodeAt(nl - 1) === 13 ? '\r\n' : '\n';
463
+ }
464
+
465
+ /**
466
+ * Non-overlapping occurrences of `needle` in `text`. Bounded by text.length.
467
+ *
468
+ * @param {string} text
469
+ * @param {string} needle
470
+ * @returns {number}
471
+ */
472
+ function countOccurrences(text, needle) {
473
+ let count = 0;
474
+ let from = 0;
475
+ for (let guard = 0; guard <= text.length; guard++) {
476
+ const at = text.indexOf(needle, from);
477
+ if (at === -1) break;
478
+ count++;
479
+ from = at + needle.length;
480
+ }
481
+ return count;
482
+ }
483
+
484
+ /**
485
+ * A CommonMark fence opener: up to three spaces, then three or more backticks
486
+ * (whose info string holds no backtick) or tildes.
487
+ *
488
+ * @param {string} line
489
+ * @returns {{ ch: string, len: number } | null}
490
+ */
491
+ function fenceOpen(line) {
492
+ const m = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
493
+ if (m === null) return null;
494
+ if (m[1][0] === '`' && m[2].includes('`')) return null;
495
+ return { ch: m[1][0], len: m[1].length };
496
+ }
497
+
498
+ /**
499
+ * @param {string} line
500
+ * @param {{ ch: string, len: number }} fence
501
+ * @returns {boolean}
502
+ */
503
+ function fenceCloses(line, fence) {
504
+ const m = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line);
505
+ return m !== null && m[1][0] === fence.ch && m[1].length >= fence.len;
506
+ }
507
+
508
+ // ---------------------------------------------------------------------------
509
+ // Parsing
510
+ // ---------------------------------------------------------------------------
511
+
512
+ /**
513
+ * @param {string} line
514
+ * @returns {Tp | null}
515
+ */
516
+ function parseTpLine(line) {
517
+ const m = TP_LINE_RE.exec(line);
518
+ if (m === null || m.groups === undefined) return null;
519
+ const g = m.groups;
520
+ return Object.freeze({
521
+ id: Number(g.n),
522
+ ac: Number(g.ac),
523
+ scenario: g.scenario,
524
+ method: /** @type {Method} */ (g.method),
525
+ files: Object.freeze(g.files === undefined ? [] : g.files.split(', ')),
526
+ line,
527
+ });
528
+ }
529
+
530
+ /**
531
+ * Parse a test plan: TP lines only, ids unique and ascending, blank lines ignored,
532
+ * and an optional `## Test Plan` heading as the first non-blank line. At least one
533
+ * TP. Any other line is refused with its line number.
534
+ *
535
+ * @param {unknown} text
536
+ * @returns {Result<Plan>}
537
+ */
538
+ function parsePlan(text) {
539
+ if (typeof text !== 'string') return fail('invalid');
540
+ if (text.length > LIMITS.INPUT_CHARS) return fail('oversize');
541
+ const rows = textLines(text);
542
+ /** @type {Tp[]} */
543
+ const tps = [];
544
+ let first = true;
545
+ for (let i = 0; i < rows.length; i++) {
546
+ const line = rows[i];
547
+ if (line === '') continue;
548
+ if (first && line === PLAN_HEADING) { first = false; continue; }
549
+ first = false;
550
+ const tp = parseTpLine(line);
551
+ if (tp === null) return fail('malformed', i + 1);
552
+ const last = tps.length > 0 ? tps[tps.length - 1].id : 0;
553
+ if (tp.id === last) return fail('duplicate', i + 1);
554
+ if (tp.id < last) return fail('order', i + 1);
555
+ tps.push(tp);
556
+ }
557
+ if (tps.length === 0) return fail('empty');
558
+ return ok(Object.freeze({ tps: Object.freeze(tps) }));
559
+ }
560
+
561
+ /**
562
+ * @param {string} line
563
+ * @returns {Claim | null}
564
+ */
565
+ function parseClaimLine(line) {
566
+ const m = CLAIM_LINE_RE.exec(line);
567
+ if (m === null || m.groups === undefined) return null;
568
+ const g = m.groups;
569
+ return Object.freeze({
570
+ target: g.target,
571
+ tp: g.tp === undefined ? null : Number(g.tp),
572
+ gate: g.gate === undefined ? null : /** @type {'validate' | 'qa'} */ (g.gate),
573
+ outcome: /** @type {Outcome} */ (g.outcome),
574
+ sha: g.sha,
575
+ by: /** @type {'test' | 'validate'} */ (g.by),
576
+ exit: g.exit === undefined ? null : Number(g.exit),
577
+ });
578
+ }
579
+
580
+ /**
581
+ * Parse the `## Claims` section. Only the LAST CLAIM_LINES lines are read: the
582
+ * section is append-only and the last valid claim per target wins, so the newest
583
+ * lines are the ones that matter. Malformed lines are counted, never echoed.
584
+ *
585
+ * @param {unknown} text
586
+ * @returns {Result<Claims>}
587
+ */
588
+ function parseClaims(text) {
589
+ if (typeof text !== 'string') return fail('invalid');
590
+ if (text.length > LIMITS.INPUT_CHARS) return fail('oversize');
591
+ let rows = textLines(text);
592
+ const firstContent = rows.findIndex(l => l !== '');
593
+ if (firstContent !== -1 && rows[firstContent] === CLAIMS_HEADING) rows = rows.slice(firstContent + 1);
594
+ const truncated = rows.length > LIMITS.CLAIM_LINES;
595
+ const window = truncated ? rows.slice(rows.length - LIMITS.CLAIM_LINES) : rows;
596
+ /** @type {Map<number, Claim>} */
597
+ const tp = new Map();
598
+ /** @type {{ validate: Claim | null, qa: Claim | null }} */
599
+ const gates = { validate: null, qa: null };
600
+ let malformed = 0;
601
+ for (const line of window) {
602
+ if (line === '') continue;
603
+ const claim = parseClaimLine(line);
604
+ if (claim === null) { malformed++; continue; }
605
+ if (claim.tp !== null) tp.set(claim.tp, claim);
606
+ else if (claim.gate !== null) gates[claim.gate] = claim;
607
+ }
608
+ return ok(Object.freeze({ tp, gates: Object.freeze(gates), malformed, truncated }));
609
+ }
610
+
611
+ /**
612
+ * Parse one evidence-exception line.
613
+ *
614
+ * @param {unknown} line
615
+ * @returns {Result<ExceptionRecord>}
616
+ */
617
+ function exception(line) {
618
+ if (typeof line !== 'string' || line.length > 400) return fail('invalid');
619
+ const m = EXCEPTION_LINE_RE.exec(line);
620
+ if (m === null) return fail('malformed', 1);
621
+ const kind = /** @type {ExceptionKind} */ (m[1]);
622
+ const who = m[2];
623
+ const prefix = '- `' + kind + '` self-attested by ' + who + ' at ';
624
+ const at = line.slice(prefix.length, prefix.length + 20);
625
+ const reason = line.slice(prefix.length + 22);
626
+ return ok(Object.freeze({ kind, login: who.startsWith('@') ? who.slice(1) : null, at, reason }));
627
+ }
628
+
629
+ /**
630
+ * Parse an `## Evidence Exceptions` section: the heading, then one or more
631
+ * exception lines — no blank line, no free text — each kind at most once.
632
+ *
633
+ * @param {unknown} text
634
+ * @returns {Result<readonly ExceptionRecord[]>}
635
+ */
636
+ function parseExceptions(text) {
637
+ if (typeof text !== 'string') return fail('invalid');
638
+ if (text.length > LIMITS.INPUT_CHARS) return fail('oversize');
639
+ const rows = textLines(text);
640
+ if (rows[0] !== EXCEPTIONS_HEADING) return fail('malformed', 1);
641
+ if (rows.length < 2) return fail('empty');
642
+ /** @type {ExceptionRecord[]} */
643
+ const out = [];
644
+ const kinds = new Set();
645
+ for (let i = 1; i < rows.length; i++) {
646
+ const r = exception(rows[i]);
647
+ if (!r.ok) return fail('malformed', i + 1);
648
+ if (kinds.has(r.value.kind)) return fail('duplicate', i + 1);
649
+ kinds.add(r.value.kind);
650
+ out.push(r.value);
651
+ }
652
+ return ok(Object.freeze(out));
653
+ }
654
+
655
+ /**
656
+ * Split the evidence file into its three sections, each returned with its heading
657
+ * line and without trailing blank lines (null when absent). Text before the first
658
+ * level-2 heading is a preamble and ignored; any other level-2 heading, or a
659
+ * repeated one, is refused.
660
+ *
661
+ * @param {unknown} text
662
+ * @returns {Result<{ testPlan: string | null, claims: string | null, exceptions: string | null }>}
663
+ */
664
+ function evidenceSections(text) {
665
+ if (typeof text !== 'string') return fail('invalid');
666
+ if (text.length > LIMITS.INPUT_CHARS) return fail('oversize');
667
+ const rows = textLines(text);
668
+ /** @type {Map<string, string[]>} */
669
+ const buffers = new Map();
670
+ /** @type {string[] | null} */
671
+ let current = null;
672
+ for (let i = 0; i < rows.length; i++) {
673
+ const line = rows[i];
674
+ if (line === '##' || line.startsWith('## ')) {
675
+ const key = SECTION_KEYS.get(line);
676
+ if (key === undefined) return fail('malformed', i + 1);
677
+ if (buffers.has(key)) return fail('duplicate', i + 1);
678
+ current = [line];
679
+ buffers.set(key, current);
680
+ continue;
681
+ }
682
+ if (current !== null) current.push(line);
683
+ }
684
+ /** @param {string} key @returns {string | null} */
685
+ const section = key => {
686
+ const lines = buffers.get(key);
687
+ if (lines === undefined) return null;
688
+ let end = lines.length;
689
+ while (end > 1 && lines[end - 1] === '') end--;
690
+ return lines.slice(0, end).join('\n');
691
+ };
692
+ return ok(Object.freeze({ testPlan: section('testPlan'), claims: section('claims'), exceptions: section('exceptions') }));
693
+ }
694
+
695
+ // ---------------------------------------------------------------------------
696
+ // Globs — `**` crosses `/`, `**/` may match no directory, `*` and `?` do not
697
+ // ---------------------------------------------------------------------------
698
+
699
+ /**
700
+ * @typedef {{ kind: 'lit', ch: string } | { kind: 'one' } | { kind: 'star' } | { kind: 'globstar' } | { kind: 'globstar-slash' }} GlobToken
701
+ */
702
+
703
+ /**
704
+ * @param {string} glob
705
+ * @returns {GlobToken[]}
706
+ */
707
+ function globTokens(glob) {
708
+ /** @type {GlobToken[]} */
709
+ const tokens = [];
710
+ for (let i = 0; i < glob.length;) {
711
+ const ch = glob[i];
712
+ if (ch === '*' && glob[i + 1] === '*') {
713
+ if (glob[i + 2] === '/') { tokens.push({ kind: 'globstar-slash' }); i += 3; }
714
+ else { tokens.push({ kind: 'globstar' }); i += 2; }
715
+ } else if (ch === '*') { tokens.push({ kind: 'star' }); i++; }
716
+ else if (ch === '?') { tokens.push({ kind: 'one' }); i++; }
717
+ else { tokens.push({ kind: 'lit', ch }); i++; }
718
+ }
719
+ return tokens;
720
+ }
721
+
722
+ /**
723
+ * Whether `glob` matches the whole of `filePath`. A position-set simulation, so
724
+ * the cost is O(|glob| × |path|) whatever the glob — no regex is built from input
725
+ * and no backtracking exists.
726
+ *
727
+ * FAILS CLOSED toward overlap: an invalid glob, an empty path or a path over
728
+ * PATH_CHARS answers `true`, because the only question this answers is "could this
729
+ * change touch the TP?" and an unanswerable one must read STALE, never verified.
730
+ *
731
+ * @param {unknown} glob
732
+ * @param {unknown} filePath
733
+ * @returns {boolean}
734
+ */
735
+ function matchGlob(glob, filePath) {
736
+ if (typeof glob !== 'string' || !GLOB_RE.test(glob)) return true;
737
+ if (typeof filePath !== 'string' || filePath.length === 0 || filePath.length > LIMITS.PATH_CHARS) return true;
738
+ const n = filePath.length;
739
+ let cur = new Uint8Array(n + 1);
740
+ let next = new Uint8Array(n + 1);
741
+ cur[0] = 1;
742
+ for (const t of globTokens(glob)) {
743
+ next.fill(0);
744
+ let alive = false;
745
+ if (t.kind === 'lit' || t.kind === 'one') {
746
+ for (let i = 0; i < n; i++) {
747
+ if (cur[i] === 1 && (t.kind === 'one' ? filePath[i] !== '/' : filePath[i] === t.ch)) {
748
+ next[i + 1] = 1;
749
+ alive = true;
750
+ }
751
+ }
752
+ } else if (t.kind === 'star') {
753
+ let carry = 0;
754
+ for (let i = 0; i <= n; i++) {
755
+ carry = cur[i] === 1 || (carry === 1 && i > 0 && filePath[i - 1] !== '/') ? 1 : 0;
756
+ next[i] = carry;
757
+ if (carry === 1) alive = true;
758
+ }
759
+ } else if (t.kind === 'globstar') {
760
+ let carry = 0;
761
+ for (let i = 0; i <= n; i++) {
762
+ if (cur[i] === 1) carry = 1;
763
+ next[i] = carry;
764
+ if (carry === 1) alive = true;
765
+ }
766
+ } else {
767
+ // `**/`: nothing, or anything that ends in `/`.
768
+ let before = 0;
769
+ for (let i = 0; i <= n; i++) {
770
+ next[i] = cur[i] === 1 || (before === 1 && filePath[i - 1] === '/') ? 1 : 0;
771
+ if (next[i] === 1) alive = true;
772
+ if (cur[i] === 1) before = 1;
773
+ }
774
+ }
775
+ if (!alive) return false;
776
+ const swap = cur;
777
+ cur = next;
778
+ next = swap;
779
+ }
780
+ return cur[n] === 1;
781
+ }
782
+
783
+ /**
784
+ * Whether any diff path could touch any of the TP's globs. Over DIFF_FILES paths,
785
+ * or any non-string path, it answers `true` (fail closed toward STALE).
786
+ *
787
+ * @param {readonly unknown[]} diff
788
+ * @param {readonly string[]} globs
789
+ * @returns {boolean}
790
+ */
791
+ function overlaps(diff, globs) {
792
+ if (diff.length > LIMITS.DIFF_FILES) return true;
793
+ for (const file of diff) {
794
+ for (const glob of globs) {
795
+ if (matchGlob(glob, file)) return true;
796
+ }
797
+ }
798
+ return false;
799
+ }
800
+
801
+ // ---------------------------------------------------------------------------
802
+ // Links
803
+ // ---------------------------------------------------------------------------
804
+
805
+ /**
806
+ * D-LINK: whether `url` lies under the repository's `htmlUrl`: `htmlUrl` must be a
807
+ * repository URL (HTML_URL_RE), and `url` must be exactly `htmlUrl + '/' + suffix`
808
+ * with the suffix in the closed LINK_SUFFIX_RE grammar. Case-sensitive, so a case
809
+ * variant of the owner or host is refused.
810
+ *
811
+ * @param {unknown} url
812
+ * @param {unknown} htmlUrl
813
+ * @returns {boolean}
814
+ */
815
+ function isRepoLink(url, htmlUrl) {
816
+ if (typeof url !== 'string' || typeof htmlUrl !== 'string') return false;
817
+ if (url.length > MAX_URL_CHARS || htmlUrl.length > MAX_URL_CHARS) return false;
818
+ if (!HTML_URL_RE.test(htmlUrl)) return false;
819
+ const prefix = htmlUrl + '/';
820
+ if (!url.startsWith(prefix)) return false;
821
+ return LINK_SUFFIX_RE.test(url.slice(prefix.length));
822
+ }
823
+
824
+ // ---------------------------------------------------------------------------
825
+ // classify — the state ladder
826
+ // ---------------------------------------------------------------------------
827
+
828
+ /**
829
+ * @param {unknown} tp
830
+ * @returns {tp is Tp}
831
+ */
832
+ function isTp(tp) {
833
+ if (!isObject(tp)) return false;
834
+ const t = /** @type {Record<string, unknown>} */ (tp);
835
+ return isIntIn(t.id, 1, LIMITS.TP_MAX)
836
+ && typeof t.method === 'string' && METHODS.includes(/** @type {Method} */ (t.method))
837
+ && Array.isArray(t.files) && t.files.length <= LIMITS.GLOBS_PER_LINE
838
+ && t.files.every(f => typeof f === 'string');
839
+ }
840
+
841
+ /**
842
+ * @param {unknown} run
843
+ * @returns {boolean} true when the record is a well-formed completed-or-not run or an expiry
844
+ */
845
+ function isRunShape(run) {
846
+ if (!isObject(run)) return false;
847
+ const r = /** @type {Record<string, unknown>} */ (run);
848
+ if (!isIntIn(r.id, 1, MAX_RUN_ID) || !isIntIn(r.attempt, 1, 999)) return false;
849
+ if (r.expired === true) return true;
850
+ return typeof r.status === 'string'
851
+ && (r.conclusion === null || typeof r.conclusion === 'string')
852
+ && typeof r.headSha === 'string'
853
+ && typeof r.url === 'string';
854
+ }
855
+
856
+ /**
857
+ * The lowest-id run matching `pred`, as a RunRef.
858
+ *
859
+ * @param {readonly Run[]} runs
860
+ * @param {(r: Run) => boolean} pred
861
+ * @returns {RunRef}
862
+ */
863
+ function firstRun(runs, pred) {
864
+ /** @type {Run | null} */
865
+ let best = null;
866
+ for (const r of runs) {
867
+ if (pred(r) && (best === null || r.id < best.id)) best = r;
868
+ }
869
+ return best === null ? null : Object.freeze({ id: best.id, attempt: best.attempt });
870
+ }
871
+
872
+ /**
873
+ * @typedef {{ tp: Tp, claim: Claim | null, facts: Facts, head: string, runs: readonly Run[],
874
+ * verifyingSha: string }} LadderInput
875
+ * Normalised once, before the arms run. `runs` is [] for non-ci TPs.
876
+ * @typedef {{ state: State, test: (x: LadderInput) => boolean, run?: (x: LadderInput) => RunRef }} Arm
877
+ */
878
+
879
+ /** @param {Run} r @returns {boolean} */
880
+ const runUnsettled = r => !isRunShape(r)
881
+ || r.expired === true
882
+ || r.status !== 'completed'
883
+ || typeof r.conclusion !== 'string'
884
+ || UNSETTLED_CONCLUSIONS.has(r.conclusion)
885
+ || !(PASSING_CONCLUSIONS.has(r.conclusion) || FAILING_CONCLUSIONS.has(r.conclusion));
886
+
887
+ /** @param {Run} r @returns {boolean} */
888
+ const runFailed = r => typeof r.conclusion === 'string' && FAILING_CONCLUSIONS.has(r.conclusion);
889
+
890
+ /** @param {LadderInput} x @returns {boolean} */
891
+ const isCi = x => x.tp.method === 'ci';
892
+
893
+ /** @param {LadderInput} x @returns {boolean} */
894
+ function needsDiff(x) {
895
+ return x.claim !== null && x.claim.sha !== x.head && x.tp.files.length > 0;
896
+ }
897
+
898
+ /**
899
+ * D-LADDER: the arms, in the order they are tried. PRECEDENCE is derived from
900
+ * this table. Every arm but the last is a POSITIVE match; the last is the
901
+ * conservative default (applies PF-075: a verified state is only ever reached by
902
+ * a positive conjunction, never because nothing else matched).
903
+ *
904
+ * @type {readonly Arm[]}
905
+ */
906
+ const ARMS = Object.freeze([
907
+ {
908
+ // No usable claim, a SKIP, a claim SHA outside the PR, or (refresh mode) TP
909
+ // text that no longer matches the trusted record.
910
+ state: 'UNVERIFIED',
911
+ test: x => x.claim === null
912
+ || x.claim.outcome === 'SKIP'
913
+ || x.facts.inPr === false
914
+ || x.facts.textMatches === false,
915
+ },
916
+ {
917
+ // Something needed to decide could not be resolved — never a silent pass.
918
+ state: 'INDETERMINATE',
919
+ test: x => !isSha40(x.facts.head)
920
+ || x.facts.inPr !== true
921
+ || (needsDiff(x) && !Array.isArray(x.facts.diff))
922
+ || (isCi(x) && (
923
+ !Array.isArray(x.facts.runs)
924
+ || x.facts.runs.length > LIMITS.RUNS_PER_SHA
925
+ || (x.verifyingSha !== /** @type {Claim} */ (x.claim).sha && x.verifyingSha !== x.head)
926
+ || x.runs.some(runUnsettled))),
927
+ run: x => (isCi(x) && Array.isArray(x.facts.runs) ? firstRun(x.runs, r => isRunShape(r) && runUnsettled(r)) : null),
928
+ },
929
+ {
930
+ // The claim is at an older commit, and the change since may touch the TP.
931
+ state: 'STALE',
932
+ test: x => /** @type {Claim} */ (x.claim).sha !== x.head
933
+ && (x.tp.files.length === 0 || overlaps(/** @type {readonly unknown[]} */ (x.facts.diff), x.tp.files)),
934
+ },
935
+ {
936
+ state: 'FAILED',
937
+ test: x => {
938
+ const claim = /** @type {Claim} */ (x.claim);
939
+ return claim.outcome === 'FAIL'
940
+ || (x.tp.method === 'local' && claim.exit !== null && claim.exit !== 0)
941
+ || (isCi(x) && x.runs.some(runFailed));
942
+ },
943
+ run: x => (isCi(x) ? firstRun(x.runs, runFailed) : null),
944
+ },
945
+ {
946
+ // A positive conjunction: a ci PASS, at least one run, every latest attempt
947
+ // passing with at least one success, each at exactly the verifying SHA and
948
+ // each linked under this repository.
949
+ state: 'VERIFIED-CI',
950
+ test: x => isCi(x)
951
+ && /** @type {Claim} */ (x.claim).outcome === 'PASS'
952
+ && x.runs.length >= 1
953
+ && x.runs.every(r => typeof r.conclusion === 'string' && PASSING_CONCLUSIONS.has(r.conclusion)
954
+ && r.headSha === x.verifyingSha
955
+ && isRepoLink(r.url, x.facts.htmlUrl))
956
+ && x.runs.some(r => r.conclusion === 'success'),
957
+ run: x => firstRun(x.runs, r => r.conclusion === 'success'),
958
+ },
959
+ {
960
+ // A local PASS with exit 0, a manual PASS, or a ci PASS whose SHA has no runs
961
+ // at all (labelled run:none, and never counted as VERIFIED-CI).
962
+ state: 'ATTESTED-LOCAL',
963
+ test: x => {
964
+ const claim = /** @type {Claim} */ (x.claim);
965
+ if (claim.outcome !== 'PASS') return false;
966
+ if (x.tp.method === 'local') return claim.exit === 0;
967
+ if (x.tp.method === 'manual') return true;
968
+ return x.runs.length === 0;
969
+ },
970
+ run: x => (isCi(x) ? 'none' : null),
971
+ },
972
+ {
973
+ state: 'UNVERIFIED',
974
+ test: () => true,
975
+ },
976
+ ].map(arm => Object.freeze(arm)));
977
+
978
+ /**
979
+ * D-LADDER: the order `classify` tries its arms in — first match wins, and the
980
+ * terminal arm is the conservative UNVERIFIED, so no state is ever reached by
981
+ * exhaustion (applies PF-075). DERIVED from ARMS, so the order the contract states
982
+ * (parity-pinned to this list) and the order the code runs cannot drift apart.
983
+ */
984
+ const PRECEDENCE = Object.freeze(/** @type {State[]} */ (ARMS.map(arm => arm.state)));
985
+
986
+ /** The verdict for a TP that is not a TP: UNVERIFIED, resting on no claim. */
987
+ const NO_VERDICT = Object.freeze(/** @type {Verdict} */ ({ state: 'UNVERIFIED', sha: null, outcome: null, run: null, exit: null }));
988
+
989
+ /**
990
+ * Classify one TP from the facts the caller gathered (see the Facts typedef).
991
+ * Pure and total: any malformed fact reads as unresolved, never as a pass.
992
+ *
993
+ * @param {Tp} tp
994
+ * @param {Facts} facts
995
+ * @returns {Verdict}
996
+ */
997
+ function classify(tp, facts) {
998
+ const f = /** @type {Facts} */ (isObject(facts) ? facts : {});
999
+ if (!isTp(tp)) return NO_VERDICT;
1000
+ const raw = /** @type {unknown} */ (f.claim);
1001
+ const claim = isObject(raw)
1002
+ && /** @type {Claim} */ (raw).tp === tp.id
1003
+ && isSha40(/** @type {Claim} */ (raw).sha)
1004
+ && CLAIM_OUTCOMES.includes(/** @type {Claim} */ (raw).outcome)
1005
+ && (/** @type {Claim} */ (raw).exit === null || isIntIn(/** @type {Claim} */ (raw).exit, 0, 255))
1006
+ ? /** @type {Claim} */ (raw)
1007
+ : null;
1008
+ /** @type {LadderInput} */
1009
+ const input = {
1010
+ tp,
1011
+ claim,
1012
+ facts: f,
1013
+ head: typeof f.head === 'string' ? f.head : '',
1014
+ runs: tp.method === 'ci' && Array.isArray(f.runs) ? f.runs.slice(0, LIMITS.RUNS_PER_SHA + 1) : [],
1015
+ verifyingSha: typeof f.verifyingSha === 'string' ? f.verifyingSha : (claim === null ? '' : claim.sha),
1016
+ };
1017
+ for (const arm of ARMS) {
1018
+ if (arm.test(input)) {
1019
+ return Object.freeze({
1020
+ state: arm.state,
1021
+ sha: claim === null ? null : claim.sha,
1022
+ outcome: claim === null ? null : claim.outcome,
1023
+ run: arm.run === undefined ? null : arm.run(input),
1024
+ exit: claim === null ? null : claim.exit,
1025
+ });
1026
+ }
1027
+ }
1028
+ // Unreachable: the last arm always matches. Kept total for the type.
1029
+ return NO_VERDICT;
1030
+ }
1031
+
1032
+ // ---------------------------------------------------------------------------
1033
+ // tally
1034
+ // ---------------------------------------------------------------------------
1035
+
1036
+ /**
1037
+ * Count states. `verified` is VERIFIED-CI + ATTESTED-LOCAL; the counts always sum
1038
+ * to `total`, and any state outside STATES is refused.
1039
+ *
1040
+ * @param {readonly unknown[]} states
1041
+ * @returns {Result<Tally>}
1042
+ */
1043
+ function tally(states) {
1044
+ if (!Array.isArray(states) || states.length > LIMITS.TP_MAX) return fail('invalid');
1045
+ /** @type {Record<State, number>} */
1046
+ const counts = { 'VERIFIED-CI': 0, 'ATTESTED-LOCAL': 0, UNVERIFIED: 0, STALE: 0, FAILED: 0, INDETERMINATE: 0 };
1047
+ for (const s of states) {
1048
+ if (typeof s !== 'string' || !STATES.includes(/** @type {State} */ (s))) return fail('invalid');
1049
+ counts[/** @type {State} */ (s)]++;
1050
+ }
1051
+ return ok(Object.freeze({
1052
+ total: states.length,
1053
+ verified: counts['VERIFIED-CI'] + counts['ATTESTED-LOCAL'],
1054
+ counts: Object.freeze(counts),
1055
+ }));
1056
+ }
1057
+
1058
+ /**
1059
+ * @param {Tally} t
1060
+ * @returns {string}
1061
+ */
1062
+ function tallyLine(t) {
1063
+ return 'Verified ' + t.verified + '/' + t.total + ': '
1064
+ + STATES.map(s => s + ' ' + t.counts[s]).join(', ');
1065
+ }
1066
+
1067
+ // ---------------------------------------------------------------------------
1068
+ // Records, hashing
1069
+ // ---------------------------------------------------------------------------
1070
+
1071
+ /**
1072
+ * D-RECORD-OUTCOME: whether a record's claim and verdict agree — a SHA exactly
1073
+ * when there is an outcome, and a state RECORD_STATES admits for that outcome. An
1074
+ * outcome outside the vocabulary (a missing one included) admits no state.
1075
+ *
1076
+ * @param {unknown} state
1077
+ * @param {unknown} sha
1078
+ * @param {unknown} outcome
1079
+ * @returns {boolean}
1080
+ */
1081
+ function recordConsistent(state, sha, outcome) {
1082
+ if ((sha === null) !== (outcome === null)) return false;
1083
+ const states = RECORD_STATES.get(/** @type {Outcome | null} */ (outcome));
1084
+ return states !== undefined && states.includes(/** @type {State} */ (state));
1085
+ }
1086
+
1087
+ /**
1088
+ * @param {unknown} rec
1089
+ * @returns {rec is TpRecord}
1090
+ */
1091
+ function isRecord(rec) {
1092
+ if (!isObject(rec)) return false;
1093
+ const r = /** @type {Record<string, unknown>} */ (rec);
1094
+ const run = r.run;
1095
+ const runOk = run === null || run === 'none'
1096
+ || (isObject(run)
1097
+ && isIntIn(/** @type {Record<string, unknown>} */ (run).id, 1, MAX_RUN_ID)
1098
+ && isIntIn(/** @type {Record<string, unknown>} */ (run).attempt, 1, 999));
1099
+ return isIntIn(r.id, 1, LIMITS.TP_MAX)
1100
+ && typeof r.state === 'string' && STATES.includes(/** @type {State} */ (r.state))
1101
+ && (r.sha === null || isSha40(r.sha))
1102
+ && recordConsistent(r.state, r.sha, r.outcome)
1103
+ && runOk
1104
+ && (r.exit === null || isIntIn(r.exit, 0, 255))
1105
+ && typeof r.hash === 'string' && /^[0-9a-f]{12}$/.test(r.hash);
1106
+ }
1107
+
1108
+ /**
1109
+ * @param {unknown} e
1110
+ * @returns {e is ExceptionRecord}
1111
+ */
1112
+ function isExceptionRecord(e) {
1113
+ if (!isObject(e)) return false;
1114
+ const x = /** @type {Record<string, unknown>} */ (e);
1115
+ return typeof x.kind === 'string' && EXCEPTION_KINDS.includes(/** @type {ExceptionKind} */ (x.kind))
1116
+ && (x.login === null || (typeof x.login === 'string' && LOGIN_RE.test(x.login)))
1117
+ && typeof x.at === 'string' && UTC_RE.test(x.at)
1118
+ && (x.reason === null || (typeof x.reason === 'string' && REASON_RE.test(x.reason)));
1119
+ }
1120
+
1121
+ /**
1122
+ * @param {TpRecord} r
1123
+ * @returns {string}
1124
+ */
1125
+ function recordLine(r) {
1126
+ const run = r.run === 'none' ? ' run:none' : r.run === null ? '' : ' run:' + r.run.id + '/' + r.run.attempt;
1127
+ const exit = r.exit === null ? '' : ' exit:' + r.exit;
1128
+ return '- TP-' + r.id + ' ' + r.state + ' sha:' + (r.sha === null ? 'none' : r.sha)
1129
+ + ' out:' + (r.outcome === null ? 'none' : r.outcome) + run + exit + ' h:' + r.hash;
1130
+ }
1131
+
1132
+ /**
1133
+ * @param {ExceptionRecord} e
1134
+ * @returns {string}
1135
+ */
1136
+ function exceptionRecordLine(e) {
1137
+ return '- exception:' + e.kind + ' by:' + (e.login === null ? 'unavailable' : '@' + e.login)
1138
+ + ' at:' + e.at + ' status:self-attested';
1139
+ }
1140
+
1141
+ /**
1142
+ * The first 12 hex of the injected sha256 over `text`. A hash function that
1143
+ * throws, or returns anything but 64 lowercase hex, is refused.
1144
+ *
1145
+ * @param {unknown} sha256
1146
+ * @param {string} text
1147
+ * @returns {Result<string>}
1148
+ */
1149
+ function hex12(sha256, text) {
1150
+ if (typeof sha256 !== 'function') return fail('invalid');
1151
+ let digest;
1152
+ try {
1153
+ digest = sha256(text);
1154
+ } catch (_) {
1155
+ return fail('invalid');
1156
+ }
1157
+ if (typeof digest !== 'string' || !/^[0-9a-f]{64}$/.test(digest)) return fail('invalid');
1158
+ return ok(digest.slice(0, 12));
1159
+ }
1160
+
1161
+ /**
1162
+ * The `h:` hash of a TP: the first 12 hex of sha256 over its canonical line.
1163
+ *
1164
+ * @param {Tp} tp
1165
+ * @param {unknown} sha256 (text: string) => 64 lowercase hex
1166
+ * @returns {Result<string>}
1167
+ */
1168
+ function tpHash(tp, sha256) {
1169
+ if (!isObject(tp) || typeof tp.line !== 'string' || parseTpLine(tp.line) === null) return fail('invalid');
1170
+ return hex12(sha256, tp.line);
1171
+ }
1172
+
1173
+ /**
1174
+ * The evidence comment's dedupe key: the first 12 hex of sha256 over the record
1175
+ * lines exactly as a STUB prints them, so a comment's key can be recomputed from
1176
+ * the records it carries.
1177
+ *
1178
+ * @param {{ records: readonly TpRecord[], exceptions: readonly ExceptionRecord[] }} records
1179
+ * @param {unknown} sha256
1180
+ * @returns {Result<string>}
1181
+ */
1182
+ function dedupeKey(records, sha256) {
1183
+ if (!isObject(records) || !Array.isArray(records.records) || !Array.isArray(records.exceptions)) return fail('invalid');
1184
+ if (records.records.length > LIMITS.TP_MAX || records.exceptions.length > EXCEPTION_KINDS.length) return fail('invalid');
1185
+ if (!records.records.every(isRecord) || !records.exceptions.every(isExceptionRecord)) return fail('invalid');
1186
+ const lines = [...records.records.map(recordLine), ...records.exceptions.map(exceptionRecordLine)];
1187
+ return hex12(sha256, lines.join('\n'));
1188
+ }
1189
+
1190
+ // ---------------------------------------------------------------------------
1191
+ // Rendering
1192
+ // ---------------------------------------------------------------------------
1193
+
1194
+ /**
1195
+ * @param {unknown} plan
1196
+ * @returns {plan is Plan}
1197
+ */
1198
+ function isPlan(plan) {
1199
+ if (!isObject(plan) || !Array.isArray(/** @type {Plan} */ (plan).tps)) return false;
1200
+ const tps = /** @type {Plan} */ (plan).tps;
1201
+ if (tps.length > LIMITS.TP_MAX) return false;
1202
+ let last = 0;
1203
+ for (const tp of tps) {
1204
+ if (!isObject(tp) || typeof tp.line !== 'string') return false;
1205
+ const parsed = parseTpLine(tp.line);
1206
+ if (parsed === null || parsed.id !== tp.id || parsed.id <= last) return false;
1207
+ last = parsed.id;
1208
+ }
1209
+ return true;
1210
+ }
1211
+
1212
+ /**
1213
+ * @param {unknown} ev
1214
+ * @returns {ev is Evidence}
1215
+ */
1216
+ function isEvidence(ev) {
1217
+ if (!isObject(ev)) return false;
1218
+ const e = /** @type {Evidence} */ (ev);
1219
+ if (!Array.isArray(e.records) || e.records.length > LIMITS.TP_MAX || !e.records.every(isRecord)) return false;
1220
+ for (let i = 1; i < e.records.length; i++) {
1221
+ if (e.records[i].id <= e.records[i - 1].id) return false;
1222
+ }
1223
+ if (!Array.isArray(e.exceptions) || !e.exceptions.every(isExceptionRecord)) return false;
1224
+ const kinds = new Set(e.exceptions.map(x => x.kind));
1225
+ return kinds.size === e.exceptions.length;
1226
+ }
1227
+
1228
+ /**
1229
+ * @param {readonly string[]} inner
1230
+ * @returns {string}
1231
+ */
1232
+ function wrapBlock(inner) {
1233
+ return [MARKERS.BLOCK_START, PLAN_HEADING, ...inner, MARKERS.BLOCK_END].join('\n');
1234
+ }
1235
+
1236
+ /**
1237
+ * Escape a table cell: a backslash, then a pipe.
1238
+ *
1239
+ * @param {string} text
1240
+ * @returns {string}
1241
+ */
1242
+ function cell(text) {
1243
+ return text.replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
1244
+ }
1245
+
1246
+ /**
1247
+ * @param {Evidence} ev
1248
+ * @param {Tally} t
1249
+ * @returns {string[]}
1250
+ */
1251
+ function stubLines(ev, t) {
1252
+ return [
1253
+ MARKERS.EVIDENCE_OPEN + ' head:' + ev.head + ' key:' + ev.key + ' -->',
1254
+ EVIDENCE_HEADING + ' ' + EM_DASH + ' ' + ev.head.slice(0, 7),
1255
+ tallyLine(t),
1256
+ '',
1257
+ ...ev.records.map(recordLine),
1258
+ ...ev.exceptions.map(exceptionRecordLine),
1259
+ ];
1260
+ }
1261
+
1262
+ /**
1263
+ * The FULL additions: the scenario table (links under html_url) and the exception
1264
+ * reasons. Null when a link would fall outside the repository.
1265
+ *
1266
+ * @param {Plan} plan
1267
+ * @param {Evidence} ev
1268
+ * @returns {string[] | null}
1269
+ */
1270
+ function fullLines(plan, ev) {
1271
+ /** @type {Map<number, Tp>} */
1272
+ const byId = new Map(plan.tps.map(tp => [tp.id, tp]));
1273
+ const rows = ['', '| TP | AC | Method | State | Run | Scenario |', '|---|---|---|---|---|---|'];
1274
+ for (const r of ev.records) {
1275
+ const tp = byId.get(r.id);
1276
+ let run = EM_DASH;
1277
+ if (r.run === 'none') run = 'no runs';
1278
+ else if (r.run !== null) {
1279
+ const link = ev.htmlUrl + '/actions/runs/' + r.run.id + '/attempts/' + r.run.attempt;
1280
+ if (!isRepoLink(link, ev.htmlUrl)) return null;
1281
+ run = '[' + r.run.id + '/' + r.run.attempt + '](' + link + ')';
1282
+ }
1283
+ rows.push('| TP-' + r.id + ' | ' + (tp ? 'AC-' + tp.ac : EM_DASH) + ' | ' + (tp ? tp.method : EM_DASH)
1284
+ + ' | ' + r.state + ' | ' + run + ' | ' + (tp ? cell(tp.scenario) : EM_DASH) + ' |');
1285
+ }
1286
+ if (ev.exceptions.length > 0) {
1287
+ rows.push('', '| Exception | By | At | Reason |', '|---|---|---|---|');
1288
+ for (const e of ev.exceptions) {
1289
+ rows.push('| ' + e.kind + ' | ' + (e.login === null ? 'unavailable' : '@' + e.login) + ' | ' + e.at
1290
+ + ' | ' + (e.reason === null ? EM_DASH : cell(e.reason)) + ' |');
1291
+ }
1292
+ }
1293
+ return rows;
1294
+ }
1295
+
1296
+ /**
1297
+ * Render a test-plan block or an evidence comment.
1298
+ *
1299
+ * create the PR-creation block: markers, `## Test Plan`, every TP unticked, no state
1300
+ * block the same TPs, ticked when their record's state is verified
1301
+ * counts the block reduced to one tally line (the body-cap fallback)
1302
+ * stub the evidence comment: marker, heading, tally, machine records (D5)
1303
+ * full the stub plus the scenario table and exception reasons; falls back to
1304
+ * the stub over FULL_COMMENT_CHARS, or when html_url is not a repo URL
1305
+ *
1306
+ * `block` and `counts` need records aligned one-to-one with the plan's TPs; the
1307
+ * comment modes are driven by the records, and read scenario text from the plan
1308
+ * by id. Every emitted TP line is re-checked against TP_LINE_RE.
1309
+ *
1310
+ * @param {Plan} plan
1311
+ * @param {Evidence | null} evidence ignored by `create`
1312
+ * @param {'create' | 'block' | 'counts' | 'stub' | 'full'} mode
1313
+ * @returns {Result<{ text: string, mode: string }>}
1314
+ */
1315
+ function render(plan, evidence, mode) {
1316
+ if (!isPlan(plan)) return fail('invalid');
1317
+ if ((mode === 'create' || mode === 'block') && plan.tps.length === 0) return fail('empty');
1318
+ if (mode === 'create') {
1319
+ return ok(Object.freeze({ text: wrapBlock(plan.tps.map(tp => tp.line)), mode }));
1320
+ }
1321
+ if (!['block', 'counts', 'stub', 'full'].includes(mode) || !isEvidence(evidence)) return fail('invalid');
1322
+ const t = tally(evidence.records.map(r => r.state));
1323
+ if (!t.ok) return t;
1324
+ if (mode === 'block' || mode === 'counts') {
1325
+ if (evidence.records.length !== plan.tps.length
1326
+ || evidence.records.some((r, i) => r.id !== plan.tps[i].id)) return fail('mismatch');
1327
+ if (mode === 'counts') {
1328
+ return ok(Object.freeze({ text: wrapBlock([tallyLine(t.value) + ' (counts only: the TP lines exceed the PR body limit)']), mode }));
1329
+ }
1330
+ const lines = plan.tps.map((tp, i) => (VERIFIED_STATES.includes(evidence.records[i].state)
1331
+ ? TICKED + tp.line.slice(UNTICKED.length)
1332
+ : tp.line));
1333
+ return ok(Object.freeze({ text: wrapBlock(lines), mode }));
1334
+ }
1335
+ if (!isSha40(evidence.head) || typeof evidence.key !== 'string' || !/^[0-9a-f]{12}$/.test(evidence.key)) return fail('invalid');
1336
+ const stub = stubLines(evidence, t.value).join('\n');
1337
+ if (mode === 'stub' || !HTML_URL_RE.test(String(evidence.htmlUrl))) return ok(Object.freeze({ text: stub, mode: 'stub' }));
1338
+ const extra = fullLines(plan, evidence);
1339
+ if (extra === null) return ok(Object.freeze({ text: stub, mode: 'stub' }));
1340
+ const full = stub + '\n' + extra.join('\n');
1341
+ if (full.length > LIMITS.FULL_COMMENT_CHARS) return ok(Object.freeze({ text: stub, mode: 'stub' }));
1342
+ return ok(Object.freeze({ text: full, mode: 'full' }));
1343
+ }
1344
+
1345
+ /**
1346
+ * Parse a test-plan block (markers inclusive; LF or CRLF): either TP lines, each
1347
+ * `- [ ] ` or `- [x] `, or one counts-only line.
1348
+ *
1349
+ * @param {unknown} text
1350
+ * @returns {Result<{ kind: 'lines', plan: Plan, ticked: readonly number[] } | { kind: 'counts', total: number, verified: number }>}
1351
+ */
1352
+ function parseBlock(text) {
1353
+ if (typeof text !== 'string') return fail('invalid');
1354
+ if (text.length > LIMITS.INPUT_CHARS) return fail('oversize');
1355
+ const rows = textLines(text);
1356
+ if (rows[0] !== MARKERS.BLOCK_START) return fail('malformed', 1);
1357
+ if (rows[1] !== PLAN_HEADING) return fail('malformed', 2);
1358
+ if (rows.length < 3 || rows[rows.length - 1] !== MARKERS.BLOCK_END) return fail('malformed', rows.length);
1359
+ const inner = rows.slice(2, rows.length - 1);
1360
+ if (inner.length === 0) return fail('empty');
1361
+ if (inner.length === 1) {
1362
+ const counts = COUNTS_LINE_RE.exec(inner[0]);
1363
+ if (counts !== null && counts.groups !== undefined) {
1364
+ return ok(Object.freeze({ kind: /** @type {'counts'} */ ('counts'), total: Number(counts.groups.total), verified: Number(counts.groups.verified) }));
1365
+ }
1366
+ }
1367
+ /** @type {number[]} */
1368
+ const ticked = [];
1369
+ const canonical = [];
1370
+ for (let i = 0; i < inner.length; i++) {
1371
+ const line = inner[i];
1372
+ const isTicked = line.startsWith(TICKED);
1373
+ const tp = parseTpLine(isTicked ? UNTICKED + line.slice(TICKED.length) : line);
1374
+ if (tp === null) return fail('malformed', i + 3);
1375
+ if (isTicked) ticked.push(tp.id);
1376
+ canonical.push(tp.line);
1377
+ }
1378
+ const plan = parsePlan(canonical.join('\n'));
1379
+ if (!plan.ok) return fail(plan.error.code, plan.error.line + 2);
1380
+ return ok(Object.freeze({ kind: /** @type {'lines'} */ ('lines'), plan: plan.value, ticked: Object.freeze(ticked) }));
1381
+ }
1382
+
1383
+ /**
1384
+ * Parse an evidence comment: the marker on the first line, then any TP and
1385
+ * exception records (other lines — prose, headings, table rows, and any line
1386
+ * outside the record grammar — are skipped, so such a TP simply has no record).
1387
+ * TP ids ascend strictly; each exception kind appears at most once; a TP record
1388
+ * whose claim contradicts its state (D-RECORD-OUTCOME) makes the whole comment
1389
+ * `malformed`, since no ladder wrote it.
1390
+ *
1391
+ * @param {unknown} text
1392
+ * @returns {Result<{ head: string, key: string, records: readonly TpRecord[], exceptions: readonly ExceptionRecord[] }>}
1393
+ */
1394
+ function parseEvidenceComment(text) {
1395
+ if (typeof text !== 'string') return fail('invalid');
1396
+ if (text.length > LIMITS.INPUT_CHARS) return fail('oversize');
1397
+ const rows = textLines(text);
1398
+ if (rows.length > LIMITS.COMMENT_LINES) return fail('oversize');
1399
+ const marker = MARKERS.EVIDENCE_RE.exec(rows[0]);
1400
+ if (marker === null || marker.groups === undefined) return fail('malformed', 1);
1401
+ /** @type {TpRecord[]} */
1402
+ const records = [];
1403
+ /** @type {ExceptionRecord[]} */
1404
+ const exceptions = [];
1405
+ const kinds = new Set();
1406
+ for (let i = 1; i < rows.length; i++) {
1407
+ const rec = TP_RECORD_RE.exec(rows[i]);
1408
+ if (rec !== null && rec.groups !== undefined) {
1409
+ const g = rec.groups;
1410
+ const id = Number(g.id);
1411
+ const last = records.length > 0 ? records[records.length - 1].id : 0;
1412
+ if (id === last) return fail('duplicate', i + 1);
1413
+ if (id < last) return fail('order', i + 1);
1414
+ const sha = g.sha === 'none' ? null : g.sha;
1415
+ const outcome = g.outcome === 'none' ? null : /** @type {Outcome} */ (g.outcome);
1416
+ if (!recordConsistent(g.state, sha, outcome)) return fail('malformed', i + 1);
1417
+ /** @type {RunRef} */
1418
+ let run = null;
1419
+ if (g.noRuns !== undefined) run = 'none';
1420
+ else if (g.runId !== undefined) run = Object.freeze({ id: Number(g.runId), attempt: Number(g.attempt) });
1421
+ records.push(Object.freeze({
1422
+ id,
1423
+ state: /** @type {State} */ (g.state),
1424
+ sha,
1425
+ outcome,
1426
+ run,
1427
+ exit: g.exit === undefined ? null : Number(g.exit),
1428
+ hash: g.hash,
1429
+ }));
1430
+ continue;
1431
+ }
1432
+ const ex = EXCEPTION_RECORD_RE.exec(rows[i]);
1433
+ if (ex !== null && ex.groups !== undefined) {
1434
+ const kind = /** @type {ExceptionKind} */ (ex.groups.kind);
1435
+ if (kinds.has(kind)) return fail('duplicate', i + 1);
1436
+ kinds.add(kind);
1437
+ exceptions.push(Object.freeze({ kind, login: ex.groups.login === undefined ? null : ex.groups.login, at: ex.groups.at, reason: null }));
1438
+ }
1439
+ }
1440
+ return ok(Object.freeze({
1441
+ head: marker.groups.head,
1442
+ key: marker.groups.key,
1443
+ records: Object.freeze(records),
1444
+ exceptions: Object.freeze(exceptions),
1445
+ }));
1446
+ }
1447
+
1448
+ // ---------------------------------------------------------------------------
1449
+ // splice — only the bytes between the markers are devflow's (applies ADR-024)
1450
+ // ---------------------------------------------------------------------------
1451
+
1452
+ /**
1453
+ * D-SPLICE: locate the one test-plan block in a PR body.
1454
+ *
1455
+ * A marker counts only as a whole line at column 0 — `\n` or `\r\n` ends it,
1456
+ * nothing else — outside any fenced code block. Every occurrence of either marker
1457
+ * text anywhere in the body must be such a line, or the body is `malformed`: an
1458
+ * indented, quoted, mid-line, fenced or CR-garbled marker is text devflow cannot
1459
+ * cleanly own, so it edits nothing rather than guess. Exactly one start line
1460
+ * before exactly one end line is a block; neither is "no block"; anything else
1461
+ * (duplicates, one without the other, reversed) is malformed.
1462
+ *
1463
+ * A lone `\r` is not a line break here, though CommonMark treats it as one. A
1464
+ * marker next to a lone `\r` is therefore non-canonical and refused, so byte
1465
+ * integrity holds; what a lone `\r` can mislead is only the fence tracking, whose
1466
+ * worst case is a block rendered as code — never an edit outside the markers.
1467
+ *
1468
+ * @param {string} body
1469
+ * @returns {Result<{ found: false, openFence: boolean } | { found: true, start: number, end: number }>}
1470
+ * `start` is the start line's first index; `end` is just past the end marker's
1471
+ * text, before its line ending.
1472
+ */
1473
+ function locateBlock(body) {
1474
+ const startCount = countOccurrences(body, MARKERS.BLOCK_START);
1475
+ const endCount = countOccurrences(body, MARKERS.BLOCK_END);
1476
+ /** @type {number[]} */
1477
+ const starts = [];
1478
+ /** @type {number[]} */
1479
+ const ends = [];
1480
+ /** @type {{ ch: string, len: number } | null} */
1481
+ let fence = null;
1482
+ let pos = 0;
1483
+ for (let guard = 0; guard <= body.length; guard++) {
1484
+ const nl = body.indexOf('\n', pos);
1485
+ const lineEnd = nl === -1 ? body.length : nl;
1486
+ const contentEnd = nl !== -1 && lineEnd > pos && body.charCodeAt(lineEnd - 1) === 13 ? lineEnd - 1 : lineEnd;
1487
+ const content = body.slice(pos, contentEnd);
1488
+ if (fence !== null) {
1489
+ // A marker line inside a fence is not collected, so the occurrence check
1490
+ // below refuses it.
1491
+ if (fenceCloses(content, fence)) fence = null;
1492
+ } else if (content === MARKERS.BLOCK_START) {
1493
+ starts.push(pos);
1494
+ } else if (content === MARKERS.BLOCK_END) {
1495
+ ends.push(pos);
1496
+ } else {
1497
+ fence = fenceOpen(content);
1498
+ }
1499
+ if (nl === -1) break;
1500
+ pos = nl + 1;
1501
+ }
1502
+ if (starts.length !== startCount || ends.length !== endCount) return fail('malformed');
1503
+ if (starts.length === 0 && ends.length === 0) return ok({ found: /** @type {false} */ (false), openFence: fence !== null });
1504
+ if (starts.length !== 1 || ends.length !== 1 || starts[0] > ends[0]) return fail('malformed');
1505
+ return ok({ found: /** @type {true} */ (true), start: starts[0], end: ends[0] + MARKERS.BLOCK_END.length });
1506
+ }
1507
+
1508
+ /**
1509
+ * The block's lines, when it is a well-formed block: LF only, the start marker
1510
+ * first and the end marker last, and between them no marker text, no HTML
1511
+ * comment and no fence. Null otherwise.
1512
+ *
1513
+ * @param {string} block
1514
+ * @returns {string[] | null}
1515
+ */
1516
+ function blockLines(block) {
1517
+ if (block.includes('\r')) return null;
1518
+ const lines = block.split('\n');
1519
+ if (lines.length < 2 || lines[0] !== MARKERS.BLOCK_START || lines[lines.length - 1] !== MARKERS.BLOCK_END) return null;
1520
+ for (let i = 1; i < lines.length - 1; i++) {
1521
+ const l = lines[i];
1522
+ if (l.includes(MARKERS.BLOCK_START) || l.includes(MARKERS.BLOCK_END)
1523
+ || l.includes('<!--') || l.includes('-->') || fenceOpen(l) !== null) return null;
1524
+ }
1525
+ return lines;
1526
+ }
1527
+
1528
+ /**
1529
+ * The raw text of the body's test-plan block (markers inclusive, original line
1530
+ * endings), null when the body has none, or `malformed`.
1531
+ *
1532
+ * @param {unknown} body
1533
+ * @returns {Result<string | null>}
1534
+ */
1535
+ function findBlock(body) {
1536
+ if (typeof body !== 'string') return fail('invalid');
1537
+ if (body.length > LIMITS.INPUT_CHARS) return fail('oversize');
1538
+ const loc = locateBlock(body);
1539
+ if (!loc.ok) return loc;
1540
+ return ok(loc.value.found ? body.slice(loc.value.start, loc.value.end) : null);
1541
+ }
1542
+
1543
+ /**
1544
+ * D-SPLICE: put `block` into `body`, changing nothing else.
1545
+ *
1546
+ * With a block present, only the bytes from the start marker to the end of the end
1547
+ * marker's text are replaced; every byte before and after — the end marker's own
1548
+ * line ending included — is the body's own and stays identical. The block takes
1549
+ * the body's line ending (detectEol). With no block, it is appended after one
1550
+ * blank line — refused as `malformed` when the body ends inside an open fence,
1551
+ * where it would render as code. Idempotent: splicing the result again yields the
1552
+ * same bytes.
1553
+ *
1554
+ * @param {unknown} body the PR body (hostile)
1555
+ * @param {unknown} block a rendered block (render's create/block/counts output)
1556
+ * @returns {Result<string>}
1557
+ */
1558
+ function splice(body, block) {
1559
+ if (typeof body !== 'string' || typeof block !== 'string') return fail('invalid');
1560
+ if (body.length > LIMITS.INPUT_CHARS) return fail('oversize');
1561
+ const lines = blockLines(block);
1562
+ if (lines === null) return fail('invalid');
1563
+ const loc = locateBlock(body);
1564
+ if (!loc.ok) return loc;
1565
+ const eol = detectEol(body);
1566
+ const rendered = lines.join(eol);
1567
+ if (loc.value.found) {
1568
+ return ok(body.slice(0, loc.value.start) + rendered + body.slice(loc.value.end));
1569
+ }
1570
+ if (loc.value.openFence) return fail('malformed');
1571
+ if (body === '') return ok(rendered);
1572
+ return ok(body + (body.endsWith('\n') ? eol : eol + eol) + rendered);
1573
+ }
1574
+
1575
+ /**
1576
+ * Splice the first of `blocks` whose result fits BODY_CHARS — pass the full block
1577
+ * first and the counts-only block second. `oversize` when none fits.
1578
+ *
1579
+ * @param {unknown} body
1580
+ * @param {readonly string[]} blocks
1581
+ * @returns {Result<{ body: string, index: number }>}
1582
+ */
1583
+ function spliceFit(body, blocks) {
1584
+ if (!Array.isArray(blocks) || blocks.length === 0 || blocks.length > 4) return fail('invalid');
1585
+ for (let i = 0; i < blocks.length; i++) {
1586
+ const r = splice(body, blocks[i]);
1587
+ if (!r.ok) return r;
1588
+ if (r.value.length <= LIMITS.BODY_CHARS) return ok(Object.freeze({ body: r.value, index: i }));
1589
+ }
1590
+ return fail('oversize');
1591
+ }
1592
+
1593
+ // ---------------------------------------------------------------------------
1594
+ // trust — the one implementation of the trust rule
1595
+ // ---------------------------------------------------------------------------
1596
+
1597
+ /**
1598
+ * @param {unknown} ctx
1599
+ * @returns {{ viewer: string, prAuthor: string, crossRepo: boolean, permissions: TrustContext['permissions'] | null }}
1600
+ */
1601
+ function trustContext(ctx) {
1602
+ const c = /** @type {Record<string, unknown>} */ (isObject(ctx) ? ctx : {});
1603
+ const perms = /** @type {{ get?: unknown, size?: unknown } | null} */ (isObject(c.permissions) ? c.permissions : null);
1604
+ const usable = perms !== null && typeof perms.get === 'function' && typeof perms.size === 'number';
1605
+ return {
1606
+ viewer: typeof c.viewer === 'string' ? c.viewer : '',
1607
+ prAuthor: typeof c.prAuthor === 'string' ? c.prAuthor : '',
1608
+ // Unknown is treated as a fork: the exclusion only narrows trust.
1609
+ crossRepo: c.isCrossRepository !== false,
1610
+ permissions: usable ? /** @type {TrustContext['permissions']} */ (perms) : null,
1611
+ };
1612
+ }
1613
+
1614
+ /**
1615
+ * Whether the association arm may consider `actor` at all — before any lookup.
1616
+ *
1617
+ * @param {Actor} actor
1618
+ * @param {ReturnType<typeof trustContext>} c
1619
+ * @returns {boolean}
1620
+ */
1621
+ function associationEligible(actor, c) {
1622
+ const login = actor.login;
1623
+ if (!TRUSTED_ASSOCIATIONS.includes(actor.association)) return false;
1624
+ if (!LOGIN_RE.test(login) || login.endsWith('[bot]')) return false;
1625
+ if (c.crossRepo && login.toLowerCase() === c.prAuthor.toLowerCase()) return false;
1626
+ return true;
1627
+ }
1628
+
1629
+ /**
1630
+ * @param {unknown} actor
1631
+ * @returns {actor is Actor}
1632
+ */
1633
+ function isActor(actor) {
1634
+ return isObject(actor)
1635
+ && typeof /** @type {Actor} */ (actor).login === 'string'
1636
+ && typeof /** @type {Actor} */ (actor).association === 'string';
1637
+ }
1638
+
1639
+ /**
1640
+ * D-TRUST: whether the author of a PR comment, review thread or review is
1641
+ * trusted. The rule's one prose statement lives in the git skill's generated
1642
+ * `references/trust-rule.md`; this function is its one implementation, and the
1643
+ * two are parity-pinned. It reads only the constants above and the lookups the
1644
+ * caller made for exactly the logins permissionLookups named. A permissions map
1645
+ * larger than TRUST_LOOKUPS is evidence the cap was bypassed, so it trusts no
1646
+ * lookup at all.
1647
+ *
1648
+ * @param {Actor} actor
1649
+ * @param {TrustContext} ctx
1650
+ * @returns {boolean}
1651
+ */
1652
+ function trust(actor, ctx) {
1653
+ if (!isActor(actor)) return false;
1654
+ const c = trustContext(ctx);
1655
+ if (c.viewer.length > 0 && actor.login === c.viewer) return true;
1656
+ if (!associationEligible(actor, c)) return false;
1657
+ if (c.permissions === null || c.permissions.size > LIMITS.TRUST_LOOKUPS) return false;
1658
+ const permission = c.permissions.get(actor.login);
1659
+ return typeof permission === 'string' && TRUSTED_PERMISSIONS.includes(permission);
1660
+ }
1661
+
1662
+ /**
1663
+ * The logins the caller must look up for `trust`, in first-seen order: each
1664
+ * association-eligible login once, never the viewer, and at most TRUST_LOOKUPS.
1665
+ * Call it ONCE per spawn with every actor, so the cap is per spawn; a login past
1666
+ * the cap is never looked up and so never trusted.
1667
+ *
1668
+ * @param {readonly Actor[]} actors
1669
+ * @param {TrustContext} ctx
1670
+ * @returns {readonly string[]}
1671
+ */
1672
+ function permissionLookups(actors, ctx) {
1673
+ if (!Array.isArray(actors)) return Object.freeze([]);
1674
+ const c = trustContext(ctx);
1675
+ /** @type {string[]} */
1676
+ const out = [];
1677
+ const seen = new Set();
1678
+ const bound = Math.min(actors.length, MAX_ACTORS);
1679
+ for (let i = 0; i < bound && out.length < LIMITS.TRUST_LOOKUPS; i++) {
1680
+ const actor = actors[i];
1681
+ if (!isActor(actor)) continue;
1682
+ if (c.viewer.length > 0 && actor.login === c.viewer) continue;
1683
+ if (seen.has(actor.login) || !associationEligible(actor, c)) continue;
1684
+ seen.add(actor.login);
1685
+ out.push(actor.login);
1686
+ }
1687
+ return Object.freeze(out);
1688
+ }
1689
+
1690
+ // ---------------------------------------------------------------------------
1691
+ // The EVIDENCE line
1692
+ // ---------------------------------------------------------------------------
1693
+
1694
+ /**
1695
+ * @param {unknown} fields
1696
+ * @returns {string | null} null when any field is out of its vocabulary or the fields disagree
1697
+ */
1698
+ function checkEvidenceFields(fields) {
1699
+ if (!isObject(fields)) return null;
1700
+ const f = /** @type {EvidenceFields} */ (fields);
1701
+ if (!isIntIn(f.pr, 1, 9999999999) || !isSha40(f.head) || !isIntIn(f.total, 0, LIMITS.TP_MAX)) return null;
1702
+ if (!isObject(f.counts)) return null;
1703
+ let sum = 0;
1704
+ for (const s of STATES) {
1705
+ if (!isIntIn(f.counts[s], 0, LIMITS.TP_MAX)) return null;
1706
+ sum += f.counts[s];
1707
+ }
1708
+ if (sum !== f.total) return null;
1709
+ if (!Array.isArray(f.stale) || f.stale.length !== f.counts.STALE) return null;
1710
+ for (let i = 0; i < f.stale.length; i++) {
1711
+ if (!isIntIn(f.stale[i], 1, LIMITS.TP_MAX) || (i > 0 && f.stale[i] <= f.stale[i - 1])) return null;
1712
+ }
1713
+ if (!Array.isArray(f.exceptions)) return null;
1714
+ const kinds = EXCEPTION_KINDS.filter(k => f.exceptions.includes(k));
1715
+ if (kinds.length !== f.exceptions.length || kinds.some((k, i) => f.exceptions[i] !== k)) return null;
1716
+ if (!['yes', 'no', 'unchecked'].includes(f.approval)) return null;
1717
+ if (typeof f.key !== 'string' || !/^[0-9a-f]{12}$/.test(f.key)) return null;
1718
+ if (!['yes', 'no', 'n/a'].includes(f.posted) || !['same', 'changed'].includes(f.body)) return null;
1719
+ return 'EVIDENCE pr:' + f.pr + ' head:' + f.head + ' total:' + f.total + ' '
1720
+ + STATES.map(s => s + ':' + f.counts[s]).join(' ')
1721
+ + ' stale:' + (f.stale.length === 0 ? 'none' : f.stale.map(id => 'TP-' + id).join(','))
1722
+ + ' exceptions:' + (kinds.length === 0 ? 'none' : kinds.join(','))
1723
+ + ' approval:' + f.approval + ' key:' + f.key + ' posted:' + f.posted + ' body:' + f.body;
1724
+ }
1725
+
1726
+ /**
1727
+ * Compose the EVIDENCE line — refusing, rather than printing, fields that are out
1728
+ * of vocabulary or inconsistent (counts that do not sum to total; a `stale` list
1729
+ * that is not STALE ascending ids).
1730
+ *
1731
+ * @param {EvidenceFields} fields
1732
+ * @returns {Result<string>}
1733
+ */
1734
+ function formatEvidenceLine(fields) {
1735
+ const line = checkEvidenceFields(fields);
1736
+ if (line === null || !EVIDENCE_LINE_RE.test(line)) return fail('invalid');
1737
+ return ok(line);
1738
+ }
1739
+
1740
+ /**
1741
+ * Parse an EVIDENCE line, holding the same consistency formatEvidenceLine does:
1742
+ * a line is accepted only when re-formatting its fields reproduces it byte for byte.
1743
+ *
1744
+ * @param {unknown} line
1745
+ * @returns {Result<EvidenceFields>}
1746
+ */
1747
+ function parseEvidenceLine(line) {
1748
+ if (typeof line !== 'string' || line.length > 4096) return fail('invalid');
1749
+ const m = EVIDENCE_LINE_RE.exec(line);
1750
+ if (m === null || m.groups === undefined) return fail('malformed', 1);
1751
+ const g = m.groups;
1752
+ /** @type {EvidenceFields} */
1753
+ const fields = {
1754
+ pr: Number(g.pr),
1755
+ head: g.head,
1756
+ total: Number(g.total),
1757
+ counts: {
1758
+ 'VERIFIED-CI': Number(g.verifiedCi),
1759
+ 'ATTESTED-LOCAL': Number(g.attestedLocal),
1760
+ UNVERIFIED: Number(g.unverified),
1761
+ STALE: Number(g.staleCount),
1762
+ FAILED: Number(g.failed),
1763
+ INDETERMINATE: Number(g.indeterminate),
1764
+ },
1765
+ stale: g.stale === 'none' ? [] : g.stale.split(',').map(id => Number(id.slice(3))),
1766
+ exceptions: g.exceptions === 'none' ? [] : /** @type {ExceptionKind[]} */ (g.exceptions.split(',')),
1767
+ approval: /** @type {EvidenceFields['approval']} */ (g.approval),
1768
+ key: g.key,
1769
+ posted: /** @type {EvidenceFields['posted']} */ (g.posted),
1770
+ body: /** @type {EvidenceFields['body']} */ (g.body),
1771
+ };
1772
+ if (checkEvidenceFields(fields) !== line) return fail('invalid', 1);
1773
+ return ok(Object.freeze({
1774
+ ...fields,
1775
+ counts: Object.freeze(fields.counts),
1776
+ stale: Object.freeze(fields.stale),
1777
+ exceptions: Object.freeze(fields.exceptions),
1778
+ }));
1779
+ }
1780
+
1781
+ // ---------------------------------------------------------------------------
1782
+ // The wave block (D-WAVE)
1783
+ // ---------------------------------------------------------------------------
1784
+
1785
+ /**
1786
+ * Parse one wave row: seven ` | `-separated cells inside `| ` and ` |`, each in its
1787
+ * closed vocabulary. No cell value holds ` | `, so the split is unambiguous and a
1788
+ * forged cell leaves the row with the wrong count. A BLOCKED row never ran, so
1789
+ * its last four cells are `—`. Null when the row is outside the grammar; the
1790
+ * caller checks the T sequence.
1791
+ *
1792
+ * @param {string} line
1793
+ * @returns {Omit<WaveRow, 'line'> | null}
1794
+ */
1795
+ function parseWaveRow(line) {
1796
+ if (!line.startsWith('| ') || !line.endsWith(' |')) return null;
1797
+ const cells = line.slice(2, -2).split(' | ');
1798
+ if (cells.length !== 7) return null;
1799
+ const [t, ticket, verdict, evaluate, test, surviving, coverage] = cells;
1800
+ if (!WAVE_T_RE.test(t) || !WAVE_TICKET_RE.test(ticket)
1801
+ || !WAVE_VERDICTS.includes(/** @type {WaveVerdict} */ (verdict))
1802
+ || !WAVE_GATE_VALUES.includes(evaluate) || !WAVE_GATE_VALUES.includes(test)
1803
+ || !WAVE_SURVIVING_RE.test(surviving) || !WAVE_COVERAGE_VALUES.includes(coverage)) return null;
1804
+ if (verdict === 'BLOCKED' && [evaluate, test, surviving, coverage].some(c => c !== EM_DASH)) return null;
1805
+ return {
1806
+ k: Number(t.slice(1)),
1807
+ ticket: ticket === NO_TICKET ? null : ticket,
1808
+ verdict: /** @type {WaveVerdict} */ (verdict),
1809
+ evaluate,
1810
+ test,
1811
+ surviving: surviving === EM_DASH ? null : Number(surviving),
1812
+ coverage,
1813
+ };
1814
+ }
1815
+
1816
+ /**
1817
+ * D-WAVE: parse a wave block (LF or CRLF, at most WAVE_BLOCK_CHARS). It holds
1818
+ * exactly, in order: `## Related Issues`; 0–WAVE_ROWS + 1 related lines; one
1819
+ * blank line; `## Wave Evidence`; the header and separator verbatim; 1–WAVE_ROWS
1820
+ * rows T1…Tn; then only blank lines. No leading text, no other line anywhere.
1821
+ *
1822
+ * D-WAVE-CLOSE — the cross rules, which make a wrong closing line unrepresentable:
1823
+ * - a related ref names exactly one row's Ticket (`orphan` when none — the one
1824
+ * tracking line below aside), and no ref repeats (`duplicate`) — nor does a
1825
+ * Ticket across rows (`duplicate`), so "exactly one" is never two;
1826
+ * - `Closes` names only a PASS or UNVERIFIED row (`unmerged`);
1827
+ * - every PASS or UNVERIFIED row with a Ticket has its related line (`unlinked`);
1828
+ * - so any other row carries at most one line, and that line is a `Refs`.
1829
+ *
1830
+ * D-WAVE-TRACKING: the FIRST related line may instead be the wave's tracking
1831
+ * issue — a `Refs` line whose ref names no row. It is admitted once and only
1832
+ * there: a later line naming no row is `orphan`, a second leading one included,
1833
+ * and a `Closes` naming no row is `orphan` wherever it stands, because a tracking
1834
+ * issue outlives the wave and the wave PR never closes it. The line is shape-gated
1835
+ * like every related line, so it adds no free text to the body. A leading `Refs`
1836
+ * that names a row is that row's line, never a tracking line.
1837
+ *
1838
+ * Structure is checked before the cross rules, so the first failure found is the
1839
+ * one reported. An error carries a code and a line number, never input bytes.
1840
+ *
1841
+ * @param {unknown} text
1842
+ * @returns {Result<WaveBlock>}
1843
+ */
1844
+ function parseWaveBlock(text) {
1845
+ if (typeof text !== 'string') return fail('invalid');
1846
+ if (text.length > LIMITS.WAVE_BLOCK_CHARS) return fail('oversize');
1847
+ const rows = textLines(text);
1848
+ let end = rows.length;
1849
+ while (end > 0 && rows[end - 1] === '') end--;
1850
+ if (rows[0] !== WAVE_HEADINGS[0]) return fail('malformed', 1);
1851
+
1852
+ /** @type {WaveRelated[]} */
1853
+ const related = [];
1854
+ let i = 1;
1855
+ for (; i < end && rows[i] !== ''; i++) {
1856
+ // One line per row at most, plus the tracking line (D-WAVE-TRACKING).
1857
+ if (related.length === LIMITS.WAVE_ROWS + 1) return fail('oversize', i + 1);
1858
+ const m = RELATED_LINE_RE.exec(rows[i]);
1859
+ if (m === null || m.groups === undefined) return fail('malformed', i + 1);
1860
+ const closes = m.groups.closes;
1861
+ related.push(Object.freeze({
1862
+ keyword: /** @type {'Closes' | 'Refs'} */ (closes === undefined ? 'Refs' : 'Closes'),
1863
+ ref: closes === undefined ? m.groups.refs : closes,
1864
+ line: i + 1,
1865
+ }));
1866
+ }
1867
+ // rows[i] is the one blank line; indices past `end` hold only blanks, so a
1868
+ // missing section fails the first comparison that expects a non-blank line.
1869
+ if (rows[i + 1] !== WAVE_HEADINGS[1]) return fail('malformed', i + 2);
1870
+ if (rows[i + 2] !== WAVE_TABLE_HEADER) return fail('malformed', i + 3);
1871
+ if (rows[i + 3] !== WAVE_TABLE_SEPARATOR) return fail('malformed', i + 4);
1872
+
1873
+ /** @type {WaveRow[]} */
1874
+ const table = [];
1875
+ /** @type {Map<string, WaveRow>} */
1876
+ const byTicket = new Map();
1877
+ for (let j = i + 4; j < end; j++) {
1878
+ if (table.length === LIMITS.WAVE_ROWS) return fail('oversize', j + 1);
1879
+ const parsed = parseWaveRow(rows[j]);
1880
+ if (parsed === null) return fail('malformed', j + 1);
1881
+ if (parsed.k !== table.length + 1) return fail('order', j + 1);
1882
+ const r = Object.freeze({ ...parsed, line: j + 1 });
1883
+ if (r.ticket !== null) {
1884
+ if (byTicket.has(r.ticket)) return fail('duplicate', j + 1);
1885
+ byTicket.set(r.ticket, r);
1886
+ }
1887
+ table.push(r);
1888
+ }
1889
+ if (table.length === 0) return fail('empty');
1890
+
1891
+ // D-WAVE-TRACKING: only the first line, only as `Refs`, and only naming no row.
1892
+ const lead = related[0];
1893
+ const tracking = lead !== undefined && lead.keyword === 'Refs' && !byTicket.has(lead.ref) ? lead : null;
1894
+ const rowLines = tracking === null ? related : related.slice(1);
1895
+
1896
+ /** @type {Set<string>} */
1897
+ const linked = new Set();
1898
+ for (const rel of rowLines) {
1899
+ const r = byTicket.get(rel.ref);
1900
+ if (r === undefined) return fail('orphan', rel.line);
1901
+ if (linked.has(rel.ref)) return fail('duplicate', rel.line);
1902
+ if (rel.keyword === 'Closes' && !WAVE_MERGED_VERDICTS.includes(r.verdict)) return fail('unmerged', rel.line);
1903
+ linked.add(rel.ref);
1904
+ }
1905
+ for (const r of table) {
1906
+ if (r.ticket !== null && WAVE_MERGED_VERDICTS.includes(r.verdict) && !linked.has(r.ticket)) return fail('unlinked', r.line);
1907
+ }
1908
+ return ok(Object.freeze({ tracking, related: Object.freeze(rowLines), rows: Object.freeze(table) }));
1909
+ }
1910
+
1911
+ // ---------------------------------------------------------------------------
1912
+ // Exports — frozen; verify-evidence.cjs and the unit tests are the consumers
1913
+ // ---------------------------------------------------------------------------
1914
+
1915
+ module.exports = Object.freeze({
1916
+ STATES,
1917
+ VERIFIED_STATES,
1918
+ METHODS,
1919
+ EXCEPTION_KINDS,
1920
+ PRECEDENCE,
1921
+ LIMITS,
1922
+ TRUSTED_ASSOCIATIONS,
1923
+ TRUSTED_PERMISSIONS,
1924
+ MARKERS,
1925
+ TP_LINE_RE,
1926
+ CLAIM_LINE_RE,
1927
+ EXCEPTION_LINE_RE,
1928
+ EVIDENCE_LINE_RE,
1929
+ SHA_RE,
1930
+ GLOB_RE,
1931
+ LOGIN_RE,
1932
+ WAVE_HEADINGS,
1933
+ WAVE_TABLE_HEADER,
1934
+ WAVE_VERDICTS,
1935
+ WAVE_MERGED_VERDICTS,
1936
+ WAVE_GATE_VALUES,
1937
+ RELATED_LINE_RE,
1938
+ WAVE_TICKET_RE,
1939
+ parseWaveBlock,
1940
+ parsePlan,
1941
+ parseClaims,
1942
+ parseExceptions,
1943
+ parseBlock,
1944
+ parseEvidenceComment,
1945
+ evidenceSections,
1946
+ exception,
1947
+ matchGlob,
1948
+ classify,
1949
+ tally,
1950
+ render,
1951
+ findBlock,
1952
+ splice,
1953
+ spliceFit,
1954
+ tpHash,
1955
+ dedupeKey,
1956
+ trust,
1957
+ permissionLookups,
1958
+ isRepoLink,
1959
+ formatEvidenceLine,
1960
+ parseEvidenceLine,
1961
+ });