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