devflow-kit 3.8.0 → 3.10.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 +231 -0
- package/README.md +6 -6
- package/dist/agents/code.md +10 -10
- package/dist/agents/fix.md +7 -7
- package/dist/agents/git.md +2 -2
- package/dist/agents/harden.md +7 -6
- package/dist/agents/refactor.md +6 -6
- package/dist/agents/skim.md +1 -1
- package/dist/agents/triage.md +7 -0
- package/dist/cli/commands/ambient.js +10 -2
- package/dist/cli/commands/compliance-prompts.js +9 -4
- package/dist/cli/commands/compliance.js +171 -24
- package/dist/cli/commands/curation.js +79 -23
- package/dist/cli/commands/gate-level-prompts.js +39 -23
- package/dist/cli/commands/gates.js +6 -7
- package/dist/cli/commands/init.js +150 -145
- package/dist/cli/commands/install-report.js +34 -0
- package/dist/cli/commands/legacy-sweep.js +64 -0
- package/dist/cli/commands/memory.js +59 -12
- package/dist/cli/commands/proxy.js +6 -1
- package/dist/cli/commands/rules.js +29 -12
- package/dist/cli/commands/skills.js +42 -9
- package/dist/cli/commands/tracker.js +9 -3
- package/dist/cli/commands/uninstall.js +354 -60
- package/dist/commands/bug-analysis.md +71 -122
- package/dist/commands/code-review.md +11 -11
- package/dist/commands/debug.md +2 -2
- package/dist/commands/dynamic-build.md +25 -25
- package/dist/commands/dynamic-plan.md +2 -2
- package/dist/commands/dynamic-profile.md +2 -2
- package/dist/commands/dynamic-tickets.md +2 -2
- package/dist/commands/explore.md +2 -2
- package/dist/commands/implement.md +48 -34
- package/dist/commands/plan.md +2 -2
- package/dist/commands/release.md +3 -3
- package/dist/commands/research.md +2 -2
- package/dist/commands/resolve.md +50 -46
- package/dist/commands/self-review.md +10 -9
- package/dist/compliance-off/agents/git.md +120 -0
- package/dist/compliance-off/agents/triage.md +180 -0
- package/dist/compliance-off/commands/bug-analysis.md +398 -0
- package/dist/compliance-off/commands/code-review.md +520 -0
- package/dist/compliance-off/commands/dynamic-build.md +1251 -0
- package/dist/compliance-off/commands/dynamic-tickets.md +676 -0
- package/dist/compliance-off/commands/implement.md +736 -0
- package/dist/compliance-off/commands/plan.md +671 -0
- package/dist/compliance-off/commands/release.md +309 -0
- package/dist/compliance-off/commands/resolve.md +742 -0
- package/dist/compliance-off/skills/git/references/decision-markers.md +17 -0
- package/dist/compliance-off/skills/git/references/learn-conventions.md +80 -0
- package/dist/compliance-off/skills/git/references/pr/check-ci-status.md +33 -0
- package/dist/compliance-off/skills/git/references/pr/check-merge-readiness.md +49 -0
- package/dist/compliance-off/skills/git/references/pr/fetch-review-threads.md +52 -0
- package/dist/compliance-off/skills/git/references/pr/validate-branch.md +47 -0
- package/dist/compliance-off/skills/git/references/tracker/_contract.md +33 -0
- package/dist/compliance-off/skills/git/references/tracker/_mcp.md +153 -0
- package/dist/compliance-off/skills/git/references/tracker/github/create-release.md +35 -0
- package/dist/compliance-off/skills/git/references/tracker/github/ensure-pr-ready.md +100 -0
- package/dist/compliance-off/skills/git/references/tracker/github/ensure-traceable-issue.md +74 -0
- package/dist/compliance-off/skills/git/references/tracker/github/fetch-issue.md +69 -0
- package/dist/compliance-off/skills/git/references/tracker/github/fetch-issues-batch.md +65 -0
- package/dist/compliance-off/skills/git/references/tracker/github/gather-release-evidence.md +48 -0
- package/dist/compliance-off/skills/git/references/tracker/github/setup-task.md +77 -0
- package/dist/compliance-off/skills/git/references/tracker/jira/create-release.md +35 -0
- package/dist/compliance-off/skills/git/references/tracker/jira/ensure-pr-ready.md +100 -0
- package/dist/compliance-off/skills/git/references/tracker/jira/ensure-traceable-issue.md +61 -0
- package/dist/compliance-off/skills/git/references/tracker/jira/fetch-issue.md +51 -0
- package/dist/compliance-off/skills/git/references/tracker/jira/fetch-issues-batch.md +63 -0
- package/dist/compliance-off/skills/git/references/tracker/jira/gather-release-evidence.md +47 -0
- package/dist/compliance-off/skills/git/references/tracker/jira/setup-task.md +82 -0
- package/dist/compliance-off/skills/git/references/tracker/linear/create-release.md +35 -0
- package/dist/compliance-off/skills/git/references/tracker/linear/ensure-pr-ready.md +100 -0
- package/dist/compliance-off/skills/git/references/tracker/linear/ensure-traceable-issue.md +61 -0
- package/dist/compliance-off/skills/git/references/tracker/linear/fetch-issue.md +51 -0
- package/dist/compliance-off/skills/git/references/tracker/linear/fetch-issues-batch.md +63 -0
- package/dist/compliance-off/skills/git/references/tracker/linear/gather-release-evidence.md +47 -0
- package/dist/compliance-off/skills/git/references/tracker/linear/setup-task.md +83 -0
- package/dist/compliance-off/skills/git/references/trust-rule.md +7 -0
- package/dist/core/agent-models.js +19 -4
- package/dist/core/area-pause-notice.js +44 -0
- package/dist/core/assets.js +126 -17
- package/dist/core/compliance-off-files.js +27 -0
- package/dist/core/curation-move-skip.js +4 -5
- package/dist/core/curation-queue-cleanup.js +6 -5
- package/dist/core/curation-store.js +1 -0
- package/dist/core/curation-variants.js +460 -112
- package/dist/core/evidence-policy.js +10 -8
- package/dist/core/flags.js +2 -2
- package/dist/core/installed-variants.js +63 -0
- package/dist/core/manifest.js +48 -5
- package/dist/core/mds-variants.js +700 -3
- package/dist/core/orphan-sweep.js +11 -2
- package/dist/core/plugins.js +69 -12
- package/dist/core/tracked-areas.js +138 -14
- package/dist/curation-compliance-off/agents/triage.md +176 -0
- package/dist/curation-compliance-off/commands/bug-analysis.md +349 -0
- package/dist/curation-compliance-off/commands/code-review.md +481 -0
- package/dist/curation-compliance-off/commands/dynamic-build.md +1211 -0
- package/dist/curation-compliance-off/commands/dynamic-tickets.md +634 -0
- package/dist/curation-compliance-off/commands/implement.md +689 -0
- package/dist/curation-compliance-off/commands/plan.md +631 -0
- package/dist/curation-compliance-off/commands/release.md +279 -0
- package/dist/curation-compliance-off/commands/resolve.md +692 -0
- package/dist/curation-off/agents/code.md +10 -10
- package/dist/curation-off/agents/fix.md +7 -7
- package/dist/curation-off/agents/harden.md +7 -6
- package/dist/curation-off/agents/refactor.md +6 -6
- package/dist/curation-off/agents/triage.md +7 -0
- package/dist/curation-off/commands/bug-analysis.md +69 -119
- package/dist/curation-off/commands/code-review.md +11 -11
- package/dist/curation-off/commands/debug.md +2 -2
- package/dist/curation-off/commands/dynamic-build.md +25 -25
- package/dist/curation-off/commands/explore.md +2 -2
- package/dist/curation-off/commands/implement.md +48 -34
- package/dist/curation-off/commands/plan.md +2 -2
- package/dist/curation-off/commands/release.md +1 -1
- package/dist/curation-off/commands/resolve.md +50 -46
- package/dist/curation-off/commands/self-review.md +10 -9
- package/dist/hud/components/curation-counts.js +27 -0
- package/dist/skills/git/references/pr/post-resolution-summary.md +12 -11
- package/dist/skills/git/references/pr/post-review-summary.md +12 -11
- package/dist/skills/git/references/pr/resolve-review-threads.md +1 -1
- package/dist/skills/git/references/pr/update-pr-evidence.md +2 -2
- package/dist/skills/git/references/publication-gate.md +6 -7
- package/dist/skills/git/references/tracker/github/manage-debt.md +6 -2
- package/dist/skills/git/references/tracker/jira/manage-debt.md +6 -2
- package/dist/skills/git/references/tracker/linear/manage-debt.md +6 -2
- package/dist/targets/claude-code/behavior-install.js +23 -9
- package/dist/targets/claude-code/charter-render.js +374 -0
- package/dist/targets/claude-code/compliance-install.js +32 -12
- package/dist/targets/claude-code/compliance-off-files.js +245 -0
- package/dist/targets/claude-code/curation-install.js +570 -100
- package/dist/targets/claude-code/installer.js +467 -62
- package/dist/targets/claude-code/language-stamp.js +6 -0
- package/dist/targets/claude-code/linked-folder.js +182 -0
- package/dist/targets/claude-code/reference-converge.js +118 -0
- package/dist/targets/claude-code/tracker-install.js +21 -8
- package/dist/targets/claude-code/tracker-servers.js +20 -6
- package/dist/targets/claude-code/workflow-folder-install.js +231 -0
- package/dist/variant-axes.json +109 -0
- package/package.json +1 -1
- package/src/assets/agents/_partials/_code.mds +6 -6
- package/src/assets/agents/code.mds +4 -4
- package/src/assets/agents/evaluate.md +7 -13
- package/src/assets/agents/fix.mds +1 -1
- package/src/assets/agents/git.mds +34 -2
- package/src/assets/agents/harden.mds +7 -6
- package/src/assets/agents/skim.mds +1 -1
- package/src/assets/agents/test.md +21 -12
- package/src/assets/agents/triage.mds +9 -0
- package/src/assets/agents/validate.md +27 -34
- package/src/assets/commands/_partials/_compliance.mds +5 -3
- package/src/assets/commands/_partials/_decisions.mds +10 -8
- package/src/assets/commands/_partials/_engine.mds +9 -8
- package/src/assets/commands/_partials/_publication.mds +10 -1
- package/src/assets/commands/_partials/_settings.mds +2 -2
- package/src/assets/commands/bug-analysis.mds +80 -126
- package/src/assets/commands/code-review.mds +52 -9
- package/src/assets/commands/dynamic-build.mds +109 -15
- package/src/assets/commands/dynamic-tickets.mds +13 -0
- package/src/assets/commands/implement.mds +145 -33
- package/src/assets/commands/plan.mds +19 -0
- package/src/assets/commands/release.mds +50 -1
- package/src/assets/commands/resolve.mds +229 -44
- package/src/assets/commands/self-review.mds +8 -7
- package/src/assets/mds/git/_ops.mds +29 -1
- package/src/assets/mds/git/_ops_pr.mds +20 -10
- package/src/assets/mds/git/_ops_steps.mds +27 -0
- package/src/assets/mds/git/_pr.mds +15 -15
- package/src/assets/mds/git/_references.mds +12 -7
- package/src/assets/mds/tracker/_common.mds +4 -0
- package/src/assets/mds/tracker/_github.mds +43 -4
- package/src/assets/mds/tracker/_jira.mds +44 -4
- package/src/assets/mds/tracker/_linear.mds +44 -4
- package/src/assets/rules/quality.md +2 -0
- package/src/assets/scripts/ci-wait.cjs +493 -118
- package/src/assets/scripts/hooks/assets/implement-installed +1 -0
- package/src/assets/scripts/hooks/assets/orchestrator-charter.md +13 -11
- package/src/assets/scripts/hooks/background-memory-update +79 -1
- package/src/assets/scripts/hooks/json-helper.cjs +44 -2
- package/src/assets/scripts/hooks/lib/curation-store.cjs +82 -20
- package/src/assets/scripts/hooks/lib/render-decisions.cjs +9 -1
- package/src/assets/scripts/hooks/lib/tracked-areas.cjs +226 -33
- package/src/assets/scripts/hooks/memory-permission +10 -2
- package/src/assets/scripts/hooks/memory-worker +11 -0
- package/src/assets/scripts/hooks/pre-compact-memory +11 -0
- package/src/assets/scripts/hooks/preamble +18 -8
- package/src/assets/scripts/hooks/queue-append +218 -1
- package/src/assets/scripts/hooks/session-start-context +89 -37
- package/src/assets/scripts/hooks/session-start-memory +39 -0
- package/src/assets/scripts/hooks/session-start-orchestrator +4 -2
- package/src/assets/scripts/pr-evidence.cjs +46 -4
- package/src/assets/scripts/resolve-settings.cjs +244 -30
- package/src/assets/scripts/verify-evidence.cjs +708 -124
- package/src/assets/skills/agent-references/SKILL.md +7 -1
- package/src/assets/skills/agent-references/references/commands/implement-levels.md +6 -4
- package/src/assets/skills/agent-references/references/commands/implement-revalidate.md +3 -3
- package/src/assets/skills/agent-references/references/commands/plan-levels.md +1 -1
- package/src/assets/skills/agent-references/references/commands/resolve-levels.md +3 -2
- package/src/assets/skills/agent-references/references/synthesize/bug-analysis.md +6 -2
- package/src/assets/skills/agent-references/references/synthesize/review.md +4 -4
- package/src/assets/skills/agent-references/references/triage/severity-tiers.md +43 -0
- package/src/assets/skills/agent-references/references/validate/passed-alone.md +14 -0
- package/src/assets/skills/docs-framework/SKILL.md +5 -2
- package/src/assets/skills/git/SKILL.md +3 -3
- package/src/assets/skills/git/references/github-api.md +2 -139
- package/src/assets/skills/git/references/github-posting.md +158 -0
- package/src/assets/skills/review-methodology/SKILL.md +1 -1
- package/src/assets/skills/review-methodology/references/patterns.md +1 -1
- package/src/assets/skills/review-methodology/references/report-template.md +1 -2
- package/src/assets/skills/testing/SKILL.md +2 -4
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
//
|
|
11
11
|
// Usage:
|
|
12
12
|
// node verify-evidence.cjs check tp|block|exceptions|wave <file>
|
|
13
|
+
// node verify-evidence.cjs check wave --links-only <file>
|
|
13
14
|
// node verify-evidence.cjs render --plan <file>
|
|
14
15
|
// node verify-evidence.cjs verify --pr <n> [--publication <mode>] [--evidence <file>]
|
|
15
16
|
// [--state <dir>] [--block-out <file>] [--comment-out <file>] [--stale-out <file>] [--approval]
|
|
@@ -17,11 +18,15 @@
|
|
|
17
18
|
// node verify-evidence.cjs readback --pr <n> --expect <file>
|
|
18
19
|
// node verify-evidence.cjs publish --pr <n> [--publication auto|full|off|stub] [--evidence <file>]
|
|
19
20
|
// node verify-evidence.cjs readiness --pr <n> [--require-non-author-approval true|false]
|
|
21
|
+
// [--evidence <file>] [--out <file>]
|
|
20
22
|
//
|
|
21
23
|
// stdout (D-VERIFY-STDOUT) is empty or exactly one of these shapes, each closed-vocabulary
|
|
22
24
|
// (the render block's TP lines come from the caller's own plan file):
|
|
23
|
-
// check nothing — the exit code is the answer; the diagnostic is on stderr
|
|
24
|
-
//
|
|
25
|
+
// check nothing — the exit code is the answer; the diagnostic is on stderr.
|
|
26
|
+
// `check wave --links-only` also admits the links-only wave block (the
|
|
27
|
+
// `## Related Issues` heading and `Refs #<n>` or `Refs <KEY-N>` lines, no
|
|
28
|
+
// evidence section); plain `check wave` refuses that block as malformed
|
|
29
|
+
// render the creation block: markers, `## Test Plan`, every TP unticked
|
|
25
30
|
// verify EVIDENCE pr:<n> head:<sha> total:<n> VERIFIED-CI:<n> ATTESTED-LOCAL:<n>
|
|
26
31
|
// UNVERIFIED:<n> STALE:<n> FAILED:<n> INDETERMINATE:<n> stale:<ids|none>
|
|
27
32
|
// exceptions:<kinds|none> approval:<yes|no|unchecked> key:<hex>
|
|
@@ -30,11 +35,34 @@
|
|
|
30
35
|
// readback READBACK ok (exit 0) or READBACK mismatch (exit 5)
|
|
31
36
|
// publish the `## PR Evidence` block — the heading, the EVIDENCE line, one
|
|
32
37
|
// `**Body**: … · **Comment**: …` line — then one `TRACEABILITY: DEGRADED
|
|
33
|
-
// (<reason>)` line per degraded part (exit 0), or
|
|
34
|
-
// `TRACEABILITY: DEGRADED (evidence unavailable)`
|
|
38
|
+
// (<reason>)` line per degraded part (exit 0), or exit 2 with one line:
|
|
39
|
+
// `TRACEABILITY: DEGRADED (evidence unavailable)` when it could not run,
|
|
40
|
+
// `TRACEABILITY: DEGRADED (compliance off)` when the machine manifest does not
|
|
41
|
+
// say compliance is enabled, or `TRACEABILITY: DEGRADED (compliance state
|
|
42
|
+
// unreadable)` when that manifest cannot be read. The last two come before
|
|
43
|
+
// any gh call, from the machine manifest alone
|
|
35
44
|
// readiness the `## Merge Readiness` block (exit 0), or the same block with
|
|
36
45
|
// `**Status**: DEGRADED (<reason>)` and one `TRACEABILITY: DEGRADED
|
|
37
|
-
// (<reason>)` line after it (exit 2)
|
|
46
|
+
// (<reason>)` line after it (exit 2). `--evidence <file>` reads the test
|
|
47
|
+
// plan and the claims from a local evidence file, as `verify --evidence`
|
|
48
|
+
// does. `--out <file>` also writes the file `READINESS pr:<n>
|
|
49
|
+
// head:<40hex|none> status:<READY|NOT_READY|DEGRADED> source=<local|pr>`
|
|
50
|
+
// (one line; wrapped here), then that same block. `source=local` names
|
|
51
|
+
// the `--evidence` file as the plan's source and `source=pr` names the
|
|
52
|
+
// PR's own record. Stdout does not change, and the command makes no
|
|
53
|
+
// remote write.
|
|
54
|
+
// A file an earlier run left at the out path never outlives this run: it
|
|
55
|
+
// is removed before the first read, and a block that does not parse or a
|
|
56
|
+
// write that fails leaves no out file. The one exception is a path that
|
|
57
|
+
// git tracks (below), which is left as it is
|
|
58
|
+
// A git-tracked file counts as absent, like a tracked `.devflow/config.json`
|
|
59
|
+
// (D-TRACKED-RESULT). `verify`, `publish` and `readiness` ask git about an `--evidence`
|
|
60
|
+
// file before they read it, and about `--block-out`, `--comment-out`, `--stale-out` and
|
|
61
|
+
// `--out` before they write or remove one. Only a clear "not tracked" answer lets the
|
|
62
|
+
// file be read or written. A tracked `--evidence` file, or one git cannot answer for, is
|
|
63
|
+
// dropped: the run goes on exactly as if `--evidence` was not given, and the stderr
|
|
64
|
+
// summary carries the note `evidence-ignored`. A tracked out path, or one git cannot
|
|
65
|
+
// answer for, is refused as a write failure (exit 3, empty stdout) and left untouched.
|
|
38
66
|
// No byte of a PR body, a comment, a review or a gh/git answer is ever printed: the
|
|
39
67
|
// boundary re-checks every stdout line against these shapes before writing it.
|
|
40
68
|
//
|
|
@@ -59,7 +87,8 @@
|
|
|
59
87
|
// `require.main === module` boundary is the only stdout write and the only
|
|
60
88
|
// exitCode assignment
|
|
61
89
|
// - every subprocess is spawned with an argv array (never a shell), stdin
|
|
62
|
-
// ignored
|
|
90
|
+
// ignored and a maxBuffer, and every one but the local tracked-file check
|
|
91
|
+
// (D-TRACKED-RESULT) has a timeout; every loop and every API fan-out has a
|
|
63
92
|
// fixed bound, and the whole run has a wall-clock deadline
|
|
64
93
|
// - a revision reaches git only after SHA_RE, so none can start with `-`; `--`
|
|
65
94
|
// ends the revision list of the one command that takes a pathspec (diff)
|
|
@@ -75,9 +104,10 @@ const crypto = require('crypto');
|
|
|
75
104
|
const childProcess = require('child_process');
|
|
76
105
|
|
|
77
106
|
const PE = require(path.join(__dirname, 'pr-evidence.cjs'));
|
|
78
|
-
// D-READINESS-CLASSIFIER: the CI
|
|
79
|
-
//
|
|
80
|
-
//
|
|
107
|
+
// D-READINESS-CLASSIFIER: the CI classifier, the merge gate and the workflow-list probe
|
|
108
|
+
// are ci-wait's own, so the two scripts cannot disagree; only ci-wait polls until its
|
|
109
|
+
// deadline, since readiness makes one read. Its main is guarded by
|
|
110
|
+
// `require.main === module`: requiring it starts nothing.
|
|
81
111
|
const CW = require(path.join(__dirname, 'ci-wait.cjs'));
|
|
82
112
|
|
|
83
113
|
// ---------------------------------------------------------------------------
|
|
@@ -123,11 +153,13 @@ const DEADLINE_MS = 90000;
|
|
|
123
153
|
/**
|
|
124
154
|
* D-PUBLISH-BOUNDS: `publish` runs `verify` under the deadline above, then its writes
|
|
125
155
|
* under a deadline of their own and a gh budget of their own. The writes are at most
|
|
126
|
-
*
|
|
127
|
-
* read-back,
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
156
|
+
* eight calls: two body reads (the compare-and-swap), a body edit and one retry, one
|
|
157
|
+
* read-back, then the comment. A new comment is a post and one retry (seven in all).
|
|
158
|
+
* An edit of the viewer's own comment is the edit, one retry and one read of the
|
|
159
|
+
* comment (eight in all). A scrub is bounded by its own timeout, not by the deadline,
|
|
160
|
+
* so one that starts just before the writes' deadline can end up to REDACT_TIMEOUT_MS
|
|
161
|
+
* after it. The worst case is 90 s + 60 s + 20 s; the caller's 180 s Bash timeout
|
|
162
|
+
* covers it with room for node's startup.
|
|
131
163
|
*/
|
|
132
164
|
const PUBLISH_DEADLINE_MS = 60000;
|
|
133
165
|
const PUBLISH_GH_CALLS = 8;
|
|
@@ -151,9 +183,12 @@ const PR_VIEW_MAX_BUFFER = 64 * 1024 * 1024;
|
|
|
151
183
|
const RUN_LIST_MAX_BUFFER = 1024 * 1024;
|
|
152
184
|
const RUN_VIEW_MAX_BUFFER = 65536;
|
|
153
185
|
const LINE_MAX_BUFFER = 4096;
|
|
186
|
+
/** A comment read back as a JSON string: 65,536 characters, each escaped to six, fit. */
|
|
187
|
+
const COMMENT_READ_MAX_BUFFER = 8 * 65536;
|
|
154
188
|
/** `gh pr checks` and the thread-count GraphQL answer: a thousand rows of names fit. */
|
|
155
189
|
const READINESS_MAX_BUFFER = 4 * 1024 * 1024;
|
|
156
|
-
|
|
190
|
+
/** Where line 1 of the readiness out file says the test plan came from: the `--evidence` file, or the PR's own record. */
|
|
191
|
+
const READINESS_SOURCES = Object.freeze(['local', 'pr']);
|
|
157
192
|
/** 5,000 paths of 4,096 bytes overflow this; an overflow reads as an unresolved diff. */
|
|
158
193
|
const DIFF_MAX_BUFFER = 16 * 1024 * 1024;
|
|
159
194
|
const FETCH_MAX_BUFFER = 1024 * 1024;
|
|
@@ -173,6 +208,14 @@ const PR_FIELDS = 'body,comments,reviews,author,headRefOid,baseRefOid,isCrossRep
|
|
|
173
208
|
const PR_RE = /^[1-9][0-9]{0,9}$/;
|
|
174
209
|
const SHA40_RE = /^[0-9a-f]{40}$/;
|
|
175
210
|
|
|
211
|
+
/**
|
|
212
|
+
* A pull-request comment's url, anchored: the pull number, then the numeric id in the
|
|
213
|
+
* fragment. The host is not pinned (GitHub Enterprise Server hosts work). There is no
|
|
214
|
+
* `m` flag, so `$` is the end of the string and a trailing newline is refused.
|
|
215
|
+
*/
|
|
216
|
+
const COMMENT_URL_RE = /^https:\/\/[^/\s?#]+\/[^/\s?#]+\/[^/\s?#]+\/pull\/([1-9][0-9]{0,9})#issuecomment-([1-9][0-9]{0,19})$/;
|
|
217
|
+
/** A comment id as a string of digits: up to 20, so an id beyond 2^53 stays exact. */
|
|
218
|
+
const COMMENT_ID_RE = /^[1-9][0-9]{0,19}$/;
|
|
176
219
|
/** A review state that decides a login's position on the PR (D6). */
|
|
177
220
|
const DECISIVE_REVIEW_STATES = new Set(['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED']);
|
|
178
221
|
|
|
@@ -182,6 +225,7 @@ const READBACK_LINE_RE = /^READBACK (?<outcome>ok|mismatch)$/;
|
|
|
182
225
|
|
|
183
226
|
const USAGE = [
|
|
184
227
|
'Usage: node verify-evidence.cjs check tp|block|exceptions|wave <file>',
|
|
228
|
+
' node verify-evidence.cjs check wave --links-only <file>',
|
|
185
229
|
' node verify-evidence.cjs render --plan <file>',
|
|
186
230
|
' node verify-evidence.cjs verify --pr <n> [--publication <mode>] [--evidence <file>]',
|
|
187
231
|
' [--state <dir>] [--block-out <file>] [--comment-out <file>] [--stale-out <file>] [--approval]',
|
|
@@ -189,6 +233,7 @@ const USAGE = [
|
|
|
189
233
|
' node verify-evidence.cjs readback --pr <n> --expect <file>',
|
|
190
234
|
' node verify-evidence.cjs publish --pr <n> [--publication auto|full|off|stub] [--evidence <file>]',
|
|
191
235
|
' node verify-evidence.cjs readiness --pr <n> [--require-non-author-approval true|false]',
|
|
236
|
+
' [--evidence <file>] [--out <file>]',
|
|
192
237
|
].join('\n');
|
|
193
238
|
|
|
194
239
|
// ---------------------------------------------------------------------------
|
|
@@ -215,7 +260,8 @@ const USAGE = [
|
|
|
215
260
|
* summary, never input bytes. `ghCap` replaces CAPS.GH_CALLS for a stage that
|
|
216
261
|
* carries a budget of its own (`publish`'s writes).
|
|
217
262
|
*
|
|
218
|
-
* @typedef {{ login: string, association: string, body: string, viewerDidAuthor: boolean }} PrComment
|
|
263
|
+
* @typedef {{ login: string, association: string, body: string, viewerDidAuthor: boolean, commentId: string }} PrComment
|
|
264
|
+
* `commentId` is the comment's numeric id as digits, or '' when its url did not parse.
|
|
219
265
|
* @typedef {{ login: string, association: string, state: string }} PrReview
|
|
220
266
|
* @typedef {{ number: number, head: string, base: string, body: string, author: string,
|
|
221
267
|
* isCrossRepository: boolean | undefined, comments: readonly PrComment[],
|
|
@@ -223,8 +269,9 @@ const USAGE = [
|
|
|
223
269
|
* The one `gh pr view` answer, parsed. Lists are chronological, newest last.
|
|
224
270
|
*
|
|
225
271
|
* @typedef {{ exec?: ExecFn, cwd?: string, now?: () => number, stderr?: (text: string) => void,
|
|
226
|
-
* formatLine?: (fields: object) => unknown }} MainDeps
|
|
227
|
-
* Injected by tests only; `formatLine` reaches the output gate.
|
|
272
|
+
* formatLine?: (fields: object) => unknown, manifestPath?: string }} MainDeps
|
|
273
|
+
* Injected by tests only; `formatLine` reaches the output gate. `manifestPath` replaces
|
|
274
|
+
* the machine manifest that `publish` reads; production always reads ~/.devflow/manifest.json.
|
|
228
275
|
*
|
|
229
276
|
* @typedef {{ code: number, stdout: string }} Outcome
|
|
230
277
|
*/
|
|
@@ -235,14 +282,15 @@ const USAGE = [
|
|
|
235
282
|
|
|
236
283
|
/**
|
|
237
284
|
* @typedef {{ kind: 'usage' }
|
|
238
|
-
* | { kind: 'check', what: 'tp' | 'block' | 'exceptions' | 'wave', file: string }
|
|
285
|
+
* | { kind: 'check', what: 'tp' | 'block' | 'exceptions' | 'wave', file: string, linksOnly: boolean }
|
|
239
286
|
* | { kind: 'render', plan: string }
|
|
240
287
|
* | { kind: 'verify', pr: number, publication: string, evidence: string | null, state: string | null,
|
|
241
288
|
* blockOut: string | null, commentOut: string | null, staleOut: string | null, approval: boolean }
|
|
242
289
|
* | { kind: 'splice', pr: number, state: string, block: string, out: string }
|
|
243
290
|
* | { kind: 'readback', pr: number, expect: string }
|
|
244
291
|
* | { kind: 'publish', pr: number, publication: string, evidence: string | null }
|
|
245
|
-
* | { kind: 'readiness', pr: number, requireNonAuthor: 'true' | 'false' | 'unrecognised'
|
|
292
|
+
* | { kind: 'readiness', pr: number, requireNonAuthor: 'true' | 'false' | 'unrecognised',
|
|
293
|
+
* evidence: string | null, out: string | null }} ParsedArgs
|
|
246
294
|
*/
|
|
247
295
|
|
|
248
296
|
/** @type {ParsedArgs} */
|
|
@@ -303,10 +351,12 @@ function parseArgs(argv) {
|
|
|
303
351
|
const sub = rest[0];
|
|
304
352
|
const tail = rest.slice(1);
|
|
305
353
|
if (sub === 'check') {
|
|
306
|
-
|
|
307
|
-
const
|
|
354
|
+
// `check wave --links-only <file>` is the one three-argument form: the links-only wave block is opt-in.
|
|
355
|
+
const linksOnly = tail.length === 3 && tail[0] === 'wave' && tail[1] === '--links-only';
|
|
356
|
+
if ((tail.length !== 2 && !linksOnly) || !['tp', 'block', 'exceptions', 'wave'].includes(tail[0])) return USAGE_ARGS;
|
|
357
|
+
const file = tail[tail.length - 1];
|
|
308
358
|
if (typeof file !== 'string' || file === '' || file.startsWith('-') || file.length > MAX_ARG_CHARS) return USAGE_ARGS;
|
|
309
|
-
return { kind: 'check', what: /** @type {'tp' | 'block' | 'exceptions' | 'wave'} */ (tail[0]), file };
|
|
359
|
+
return { kind: 'check', what: /** @type {'tp' | 'block' | 'exceptions' | 'wave'} */ (tail[0]), file, linksOnly };
|
|
310
360
|
}
|
|
311
361
|
if (sub === 'render') {
|
|
312
362
|
const f = parseFlags(tail, { '--plan': 'value' });
|
|
@@ -358,13 +408,20 @@ function parseArgs(argv) {
|
|
|
358
408
|
return { kind: 'publish', pr, publication, evidence };
|
|
359
409
|
}
|
|
360
410
|
if (sub === 'readiness') {
|
|
361
|
-
const f = parseFlags(tail, {
|
|
411
|
+
const f = parseFlags(tail, {
|
|
412
|
+
'--pr': 'value', '--require-non-author-approval': 'value', '--evidence': 'value', '--out': 'value',
|
|
413
|
+
});
|
|
362
414
|
const pr = f === null ? null : parsePr(flagValue(f, '--pr'));
|
|
363
415
|
if (f === null || pr === null) return USAGE_ARGS;
|
|
364
416
|
const needed = flagValue(f, '--require-non-author-approval');
|
|
365
417
|
if (needed !== null && needed !== 'true' && needed !== 'false') return USAGE_ARGS;
|
|
418
|
+
const evidence = flagValue(f, '--evidence');
|
|
419
|
+
const out = flagValue(f, '--out');
|
|
420
|
+
// The out file is never the evidence file: two spellings of one path (`./a.md` and `a.md`)
|
|
421
|
+
// and two names of one file (a hard link or a symlink) are both refused before any read.
|
|
422
|
+
if (evidence !== null && out !== null && (path.resolve(evidence) === path.resolve(out) || sameFile(evidence, out))) return USAGE_ARGS;
|
|
366
423
|
// An absent flag is the ladder's "unrecognised": it can never read READY.
|
|
367
|
-
return { kind: 'readiness', pr, requireNonAuthor: needed === null ? 'unrecognised' : needed };
|
|
424
|
+
return { kind: 'readiness', pr, requireNonAuthor: needed === null ? 'unrecognised' : needed, evidence, out };
|
|
368
425
|
}
|
|
369
426
|
return USAGE_ARGS;
|
|
370
427
|
}
|
|
@@ -373,6 +430,24 @@ function parseArgs(argv) {
|
|
|
373
430
|
// Bounded file I/O
|
|
374
431
|
// ---------------------------------------------------------------------------
|
|
375
432
|
|
|
433
|
+
/**
|
|
434
|
+
* Whether two paths name one file: the same device and inode, links followed. A path
|
|
435
|
+
* that does not exist, or cannot be inspected, names no file.
|
|
436
|
+
*
|
|
437
|
+
* @param {string} a
|
|
438
|
+
* @param {string} b
|
|
439
|
+
* @returns {boolean}
|
|
440
|
+
*/
|
|
441
|
+
function sameFile(a, b) {
|
|
442
|
+
try {
|
|
443
|
+
const first = fs.statSync(a, { bigint: true, throwIfNoEntry: false });
|
|
444
|
+
const second = fs.statSync(b, { bigint: true, throwIfNoEntry: false });
|
|
445
|
+
return first !== undefined && second !== undefined && first.dev === second.dev && first.ino === second.ino;
|
|
446
|
+
} catch (_) {
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
376
451
|
/**
|
|
377
452
|
* Read a regular file of at most MAX_FILE_BYTES as strict UTF-8, or null. The
|
|
378
453
|
* open adds O_NONBLOCK, so a FIFO put in a file's place cannot block, and the
|
|
@@ -414,19 +489,30 @@ function readTextFile(filePath) {
|
|
|
414
489
|
/**
|
|
415
490
|
* Write `text` to `filePath`, refusing anything that already exists and is not a
|
|
416
491
|
* regular file (a directory, a FIFO, a symlink). Returns whether it was written.
|
|
492
|
+
* The lstat refuses first. The open adds O_NOFOLLOW, so a link put in the file's
|
|
493
|
+
* place after the lstat is refused, never followed, and O_NONBLOCK, so a FIFO put
|
|
494
|
+
* there cannot block; the fstat re-checks the opened object.
|
|
417
495
|
*
|
|
418
496
|
* @param {string} filePath
|
|
419
497
|
* @param {string} text
|
|
420
498
|
* @returns {boolean}
|
|
421
499
|
*/
|
|
422
500
|
function writeTextFile(filePath, text) {
|
|
501
|
+
let fd;
|
|
423
502
|
try {
|
|
424
503
|
const st = fs.lstatSync(filePath, { throwIfNoEntry: false });
|
|
425
504
|
if (st !== undefined && !st.isFile()) return false;
|
|
426
|
-
|
|
505
|
+
const c = fs.constants;
|
|
506
|
+
fd = fs.openSync(filePath, c.O_WRONLY | c.O_CREAT | c.O_TRUNC | (c.O_NOFOLLOW || 0) | (c.O_NONBLOCK || 0), 0o600);
|
|
507
|
+
if (!fs.fstatSync(fd).isFile()) return false;
|
|
508
|
+
fs.writeFileSync(fd, text, 'utf8');
|
|
427
509
|
return true;
|
|
428
510
|
} catch (_) {
|
|
429
511
|
return false;
|
|
512
|
+
} finally {
|
|
513
|
+
if (fd !== undefined) {
|
|
514
|
+
try { fs.closeSync(fd); } catch (_) { /* the write already decided */ }
|
|
515
|
+
}
|
|
430
516
|
}
|
|
431
517
|
}
|
|
432
518
|
|
|
@@ -492,12 +578,13 @@ function refusedCall(reason) {
|
|
|
492
578
|
}
|
|
493
579
|
|
|
494
580
|
/**
|
|
495
|
-
* One bounded subprocess call, normalized. The argv array is a fresh copy.
|
|
581
|
+
* One bounded subprocess call, normalized. The argv array is a fresh copy. A null
|
|
582
|
+
* `timeout` sets no time limit on the call.
|
|
496
583
|
*
|
|
497
584
|
* @param {Io} io
|
|
498
585
|
* @param {string} file
|
|
499
586
|
* @param {readonly string[]} args
|
|
500
|
-
* @param {number} timeout
|
|
587
|
+
* @param {number | null} timeout
|
|
501
588
|
* @param {number} maxBuffer
|
|
502
589
|
* @returns {CallResult}
|
|
503
590
|
*/
|
|
@@ -506,7 +593,7 @@ function runCall(io, file, args, timeout, maxBuffer) {
|
|
|
506
593
|
cwd: io.cwd,
|
|
507
594
|
env: io.env,
|
|
508
595
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
509
|
-
timeout,
|
|
596
|
+
...(timeout === null ? {} : { timeout }),
|
|
510
597
|
maxBuffer,
|
|
511
598
|
windowsHide: true,
|
|
512
599
|
shell: false,
|
|
@@ -585,18 +672,103 @@ function gh(io, args, maxBuffer) {
|
|
|
585
672
|
|
|
586
673
|
/**
|
|
587
674
|
* Every git call: `-c core.fsmonitor=false` first, so no repository-configured
|
|
588
|
-
* hook runs (D-NO-FSMONITOR), and
|
|
675
|
+
* hook runs (D-NO-FSMONITOR), and refused once the deadline has passed. A null
|
|
676
|
+
* `timeout` sets no time limit on the call itself.
|
|
589
677
|
*
|
|
590
678
|
* @param {Io} io
|
|
591
679
|
* @param {readonly string[]} args
|
|
592
|
-
* @param {number} timeout
|
|
680
|
+
* @param {number | null} timeout
|
|
593
681
|
* @param {number} maxBuffer
|
|
594
682
|
* @returns {CallResult}
|
|
595
683
|
*/
|
|
596
684
|
function git(io, args, timeout, maxBuffer) {
|
|
597
685
|
const left = remaining(io);
|
|
598
686
|
if (left === 0) { io.notes.add('deadline'); return refusedCall('deadline'); }
|
|
599
|
-
return runCall(io, 'git', ['-c', 'core.fsmonitor=false', ...args], Math.min(timeout, left), maxBuffer);
|
|
687
|
+
return runCall(io, 'git', ['-c', 'core.fsmonitor=false', ...args], timeout === null ? null : Math.min(timeout, left), maxBuffer);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// ---------------------------------------------------------------------------
|
|
691
|
+
// Tracked files — D-TRACKED-RESULT
|
|
692
|
+
// ---------------------------------------------------------------------------
|
|
693
|
+
|
|
694
|
+
/** What git says, asked with LC_ALL=C, of a path that sits in no repository. */
|
|
695
|
+
const NOT_A_REPOSITORY_RE = /\bnot a git repository\b/;
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* D-TRACKED-RESULT: whether git tracks one absolute path, asked of the repository the
|
|
699
|
+
* path sits in (`-C` its directory, the name taken literally). `untracked` is a clear
|
|
700
|
+
* answer only: git exits 0 and lists nothing, or says the directory is in no
|
|
701
|
+
* repository. A listed path is `tracked`. A git that could not run or answered
|
|
702
|
+
* anything else, and a call refused past the deadline, is `unknown`. The call sets no
|
|
703
|
+
* time limit.
|
|
704
|
+
*
|
|
705
|
+
* @param {Io} io
|
|
706
|
+
* @param {string} abs
|
|
707
|
+
* @returns {'untracked' | 'tracked' | 'unknown'}
|
|
708
|
+
*/
|
|
709
|
+
function trackedStateOf(io, abs) {
|
|
710
|
+
const probe = Object.assign({}, io, { env: Object.assign({}, io.env, { LC_ALL: 'C' }) });
|
|
711
|
+
const r = git(probe, ['-C', path.dirname(abs), '--literal-pathspecs', 'ls-files', '-z', '--', path.basename(abs)], null, LINE_MAX_BUFFER);
|
|
712
|
+
if (!answered(r)) return 'unknown';
|
|
713
|
+
if (r.status === 0) return r.stdout.length === 0 ? 'untracked' : 'tracked';
|
|
714
|
+
return r.status === 128 && NOT_A_REPOSITORY_RE.test(r.stderr) ? 'untracked' : 'unknown';
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* D-TRACKED-RESULT: a git-tracked file counts as absent, like a tracked
|
|
719
|
+
* `.devflow/config.json`. A path is `untracked` only when git says so for the path as
|
|
720
|
+
* spelled and for the file it resolves to (a link is judged by both names); one tracked
|
|
721
|
+
* name makes it `tracked`, and one name git could not answer for makes it `unknown`.
|
|
722
|
+
* It never throws.
|
|
723
|
+
*
|
|
724
|
+
* @param {Io} io
|
|
725
|
+
* @param {string} file
|
|
726
|
+
* @returns {'untracked' | 'tracked' | 'unknown'}
|
|
727
|
+
*/
|
|
728
|
+
function trackedState(io, file) {
|
|
729
|
+
try {
|
|
730
|
+
const abs = path.resolve(file);
|
|
731
|
+
const names = [abs];
|
|
732
|
+
try {
|
|
733
|
+
const real = fs.realpathSync(abs);
|
|
734
|
+
if (real !== abs) names.push(real);
|
|
735
|
+
} catch (_) { /* a path that names nothing has no other name */ }
|
|
736
|
+
/** @type {'untracked' | 'tracked' | 'unknown'} */
|
|
737
|
+
let state = 'untracked';
|
|
738
|
+
for (const name of names) {
|
|
739
|
+
const one = trackedStateOf(io, name);
|
|
740
|
+
if (one === 'tracked') return 'tracked';
|
|
741
|
+
if (one === 'unknown') state = 'unknown';
|
|
742
|
+
}
|
|
743
|
+
return state;
|
|
744
|
+
} catch (_) {
|
|
745
|
+
return 'unknown';
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* D-TRACKED-RESULT: the paths a `verify`, `publish` or `readiness` run reads or writes
|
|
751
|
+
* as a result file. An out path (`--block-out`, `--comment-out`, `--stale-out`,
|
|
752
|
+
* `--out`) that is not clearly untracked is refused before any read or removal, with
|
|
753
|
+
* the write failure (exit 3): no file is written. An `--evidence` file that is not
|
|
754
|
+
* clearly untracked is dropped, so the run goes on exactly as if `--evidence` was not
|
|
755
|
+
* given; the stderr summary then carries the closed note `evidence-ignored`. Stdout
|
|
756
|
+
* keeps its grammar.
|
|
757
|
+
*
|
|
758
|
+
* @param {Io} io
|
|
759
|
+
* @param {ParsedArgs} args
|
|
760
|
+
* @returns {{ args: ParsedArgs } | { outcome: Outcome }}
|
|
761
|
+
*/
|
|
762
|
+
function admitPaths(io, args) {
|
|
763
|
+
if (args.kind !== 'verify' && args.kind !== 'publish' && args.kind !== 'readiness') return { args };
|
|
764
|
+
const outs = args.kind === 'verify' ? [args.blockOut, args.commentOut, args.staleOut]
|
|
765
|
+
: args.kind === 'readiness' ? [args.out] : [];
|
|
766
|
+
if (outs.some(file => file !== null && trackedState(io, file) !== 'untracked')) {
|
|
767
|
+
return { outcome: refuse(io, EXIT_CODES.WRITE_FAILED, 'an output file could not be written') };
|
|
768
|
+
}
|
|
769
|
+
if (args.evidence === null || trackedState(io, args.evidence) === 'untracked') return { args };
|
|
770
|
+
io.notes.add('evidence-ignored');
|
|
771
|
+
return { args: Object.assign({}, args, { evidence: null }) };
|
|
600
772
|
}
|
|
601
773
|
|
|
602
774
|
/**
|
|
@@ -625,6 +797,19 @@ function loginArg(login) {
|
|
|
625
797
|
return login;
|
|
626
798
|
}
|
|
627
799
|
|
|
800
|
+
/**
|
|
801
|
+
* Assert a comment id before it reaches gh's argv (an API path segment):
|
|
802
|
+
* COMMENT_ID_RE, so no `/`, `..`, `?` or option can ride on PR data. commentIdOf
|
|
803
|
+
* only ever yields such ids; this is that precondition held at the sink.
|
|
804
|
+
*
|
|
805
|
+
* @param {string} id
|
|
806
|
+
* @returns {string}
|
|
807
|
+
*/
|
|
808
|
+
function commentIdArg(id) {
|
|
809
|
+
if (typeof id !== 'string' || !COMMENT_ID_RE.test(id)) throw new Error('verify-evidence: unchecked comment id');
|
|
810
|
+
return id;
|
|
811
|
+
}
|
|
812
|
+
|
|
628
813
|
// ---------------------------------------------------------------------------
|
|
629
814
|
// The PR (one gh pr view)
|
|
630
815
|
// ---------------------------------------------------------------------------
|
|
@@ -646,6 +831,21 @@ function loginOf(author) {
|
|
|
646
831
|
return typeof login === 'string' && login.length <= 100 ? login : '';
|
|
647
832
|
}
|
|
648
833
|
|
|
834
|
+
/**
|
|
835
|
+
* The numeric id of a PR comment, read from its url, as the digits themselves:
|
|
836
|
+
* never Number(), so an id beyond 2^53 stays exact. '' unless the url is a bounded
|
|
837
|
+
* string of the anchored pull-comment form AND names the PR that was read.
|
|
838
|
+
*
|
|
839
|
+
* @param {unknown} url
|
|
840
|
+
* @param {number} pr
|
|
841
|
+
* @returns {string}
|
|
842
|
+
*/
|
|
843
|
+
function commentIdOf(url, pr) {
|
|
844
|
+
if (typeof url !== 'string' || url.length > MAX_URL_CHARS) return '';
|
|
845
|
+
const m = COMMENT_URL_RE.exec(url);
|
|
846
|
+
return m !== null && m[1] === String(pr) ? m[2] : '';
|
|
847
|
+
}
|
|
848
|
+
|
|
649
849
|
/**
|
|
650
850
|
* Parse the `gh pr view --json` answer into PrFacts, or null when it is not the
|
|
651
851
|
* PR that was asked for or a field has the wrong shape. Malformed list entries
|
|
@@ -671,6 +871,7 @@ function parsePrJson(json, pr) {
|
|
|
671
871
|
association: typeof x.authorAssociation === 'string' ? x.authorAssociation : '',
|
|
672
872
|
body: typeof x.body === 'string' ? x.body : '',
|
|
673
873
|
viewerDidAuthor: x.viewerDidAuthor === true,
|
|
874
|
+
commentId: commentIdOf(x.url, pr),
|
|
674
875
|
});
|
|
675
876
|
});
|
|
676
877
|
const reviews = o.reviews.slice(-CAPS.REVIEWS).map(r => {
|
|
@@ -751,6 +952,38 @@ function markerComments(pr) {
|
|
|
751
952
|
return pr.comments.filter(c => PE.MARKERS.EVIDENCE_RE.test(firstLine(c.body))).reverse();
|
|
752
953
|
}
|
|
753
954
|
|
|
955
|
+
/**
|
|
956
|
+
* @typedef {{ id: string, marker: string, editable: boolean }} EditTarget
|
|
957
|
+
* `id` is the comment's id ('' when unreadable); `marker` is its first line;
|
|
958
|
+
* `editable` is false when a later comment from another login is itself an
|
|
959
|
+
* evidence marker.
|
|
960
|
+
*/
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* The comment a publish would edit in place: the viewer's NEWEST comment whose first
|
|
964
|
+
* line starts with the evidence head prefix, whatever its key. "The viewer's" is the
|
|
965
|
+
* server-asserted `viewerDidAuthor` flag, so only an own comment is ever a target
|
|
966
|
+
* (never another login's comment, such as a maintainer's or a bot's). A malformed own
|
|
967
|
+
* comment (the prefix alone) is a valid target, so an edit repairs it.
|
|
968
|
+
*
|
|
969
|
+
* `editable` guards the trust order: verify trusts the newest evidence comment by
|
|
970
|
+
* list position, and an edit keeps its old position, so editing is safe only when
|
|
971
|
+
* no strict marker comment from another login comes after the target. A later
|
|
972
|
+
* prefix-only comment from another login is not a record and does not block. The
|
|
973
|
+
* strict trust functions (markerComments, resolveTrust, findTrustedRecord) stay as
|
|
974
|
+
* they are.
|
|
975
|
+
*
|
|
976
|
+
* @param {{ comments: readonly PrComment[] }} pr chronological, newest last
|
|
977
|
+
* @returns {EditTarget | null}
|
|
978
|
+
*/
|
|
979
|
+
function editTarget(pr) {
|
|
980
|
+
const at = pr.comments.findLastIndex(c => c.viewerDidAuthor && firstLine(c.body).startsWith(PE.MARKERS.EVIDENCE_HEAD_PREFIX));
|
|
981
|
+
if (at === -1) return null;
|
|
982
|
+
const c = pr.comments[at];
|
|
983
|
+
const editable = !pr.comments.slice(at + 1).some(x => !x.viewerDidAuthor && PE.MARKERS.EVIDENCE_RE.test(firstLine(x.body)));
|
|
984
|
+
return Object.freeze({ id: c.commentId, marker: firstLine(c.body), editable });
|
|
985
|
+
}
|
|
986
|
+
|
|
754
987
|
/**
|
|
755
988
|
* D-VERIFY-APPROVAL (D6): each login's LATEST decisive review — APPROVED,
|
|
756
989
|
* CHANGES_REQUESTED or DISMISSED — in chronological order.
|
|
@@ -1437,35 +1670,40 @@ function bodyUpdate(io, plan, ev, body) {
|
|
|
1437
1670
|
|
|
1438
1671
|
/**
|
|
1439
1672
|
* The comment (D-VERIFY-PUBLICATION) and whether it is already posted: only the
|
|
1440
|
-
* viewer's own comment whose first line is this
|
|
1441
|
-
* counts, so a spoofed marker never suppresses a post
|
|
1442
|
-
*
|
|
1673
|
+
* viewer's own NEWEST evidence comment (the edit target) whose first line is this
|
|
1674
|
+
* exact marker (head and key) counts, so a spoofed marker never suppresses a post
|
|
1675
|
+
* and an older exact match behind a newer own comment of another key reads `no`
|
|
1676
|
+
* (the newer one is what a publish edits). `off`, or nothing to record, is no
|
|
1677
|
+
* comment (`n/a`). Null when it fails to render.
|
|
1443
1678
|
*
|
|
1444
1679
|
* @param {{ tps: readonly any[] }} plan
|
|
1445
1680
|
* @param {any} ev
|
|
1446
1681
|
* @param {'full' | 'off' | 'stub'} mode
|
|
1447
|
-
* @param {
|
|
1682
|
+
* @param {EditTarget | null} target
|
|
1448
1683
|
* @returns {{ text: string, posted: 'yes' | 'no' | 'n/a' } | null}
|
|
1449
1684
|
*/
|
|
1450
|
-
function commentUpdate(plan, ev, mode,
|
|
1685
|
+
function commentUpdate(plan, ev, mode, target) {
|
|
1451
1686
|
if (mode === 'off' || (ev.records.length === 0 && ev.exceptions.length === 0)) return { text: '', posted: 'n/a' };
|
|
1452
1687
|
const rendered = PE.render(plan, ev, mode);
|
|
1453
1688
|
if (!rendered.ok) return null;
|
|
1454
1689
|
const marker = firstLine(rendered.value.text);
|
|
1455
|
-
const posted =
|
|
1690
|
+
const posted = target !== null && target.marker === marker ? 'yes' : 'no';
|
|
1456
1691
|
return { text: rendered.value.text, posted };
|
|
1457
1692
|
}
|
|
1458
1693
|
|
|
1459
1694
|
/**
|
|
1460
|
-
* --stale-out:
|
|
1461
|
-
* trusted author already published, never
|
|
1695
|
+
* --stale-out: the STALE TPs. A plan from the PR body lists only those whose text hash
|
|
1696
|
+
* equals the trusted record's — text a trusted author already published, never
|
|
1697
|
+
* unrecorded PR text (containment). A plan from the `--evidence` file is the caller's
|
|
1698
|
+
* own local text, so every STALE TP of it is listed.
|
|
1462
1699
|
*
|
|
1463
1700
|
* @param {readonly TpInput[]} inputs
|
|
1464
1701
|
* @param {readonly { state: string }[]} records
|
|
1702
|
+
* @param {boolean} fromFile whether the plan came from the evidence file
|
|
1465
1703
|
* @returns {string}
|
|
1466
1704
|
*/
|
|
1467
|
-
function staleOutText(inputs, records) {
|
|
1468
|
-
const lines = inputs.filter((x, i) => records[i].state === 'STALE' && x.recorded).map(x => x.tp.line);
|
|
1705
|
+
function staleOutText(inputs, records, fromFile) {
|
|
1706
|
+
const lines = inputs.filter((x, i) => records[i].state === 'STALE' && (fromFile || x.recorded)).map(x => x.tp.line);
|
|
1469
1707
|
return lines.length === 0 ? '' : ['## Test Plan', ...lines].join('\n') + '\n';
|
|
1470
1708
|
}
|
|
1471
1709
|
|
|
@@ -1492,24 +1730,43 @@ function writeOutputs(args, body, blockText, commentText, staleText) {
|
|
|
1492
1730
|
}
|
|
1493
1731
|
|
|
1494
1732
|
/**
|
|
1733
|
+
* @typedef {{ outcome: Outcome, target: EditTarget | null }} VerifyPass
|
|
1734
|
+
* The verify result and the viewer's edit target from the same PR snapshot (null on
|
|
1735
|
+
* every refusal). The target stays in memory: it never reaches stdout, a flag or a file.
|
|
1736
|
+
*/
|
|
1737
|
+
|
|
1738
|
+
/**
|
|
1739
|
+
* @param {Io} io
|
|
1740
|
+
* @param {number} code
|
|
1741
|
+
* @param {string} reason a fixed phrase from this file, never input bytes
|
|
1742
|
+
* @returns {VerifyPass}
|
|
1743
|
+
*/
|
|
1744
|
+
function refusedPass(io, code, reason) {
|
|
1745
|
+
return { outcome: refuse(io, code, reason), target: null };
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
/**
|
|
1749
|
+
* One verify pass: the Outcome the `verify` subcommand prints, and the edit target
|
|
1750
|
+
* the same snapshot gave. `posted` in the EVIDENCE line is taken from that target.
|
|
1751
|
+
*
|
|
1495
1752
|
* @param {Io} io
|
|
1496
1753
|
* @param {Extract<ParsedArgs, { kind: 'verify' }>} args
|
|
1497
1754
|
* @param {MainDeps} deps
|
|
1498
|
-
* @returns {
|
|
1755
|
+
* @returns {VerifyPass}
|
|
1499
1756
|
*/
|
|
1500
|
-
function
|
|
1757
|
+
function verifyPass(io, args, deps) {
|
|
1501
1758
|
// Inputs first: an unusable evidence file or state directory decides before any call.
|
|
1502
1759
|
/** @type {EvidenceInput | null} */
|
|
1503
1760
|
let evidence = null;
|
|
1504
1761
|
if (args.evidence !== null) {
|
|
1505
1762
|
evidence = loadEvidence(args.evidence);
|
|
1506
|
-
if (evidence === null) return
|
|
1763
|
+
if (evidence === null) return refusedPass(io, EXIT_CODES.INPUT_UNUSABLE, 'evidence file unusable');
|
|
1507
1764
|
if (evidence.malformed > 0) io.notes.add('malformed-claims');
|
|
1508
1765
|
}
|
|
1509
|
-
if (args.state !== null && !isRealDirectory(args.state)) return
|
|
1766
|
+
if (args.state !== null && !isRealDirectory(args.state)) return refusedPass(io, EXIT_CODES.INPUT_UNUSABLE, 'state directory unusable');
|
|
1510
1767
|
|
|
1511
1768
|
const pr = parsePrJson(prView(io, args.pr, PR_FIELDS), args.pr);
|
|
1512
|
-
if (pr === null) return
|
|
1769
|
+
if (pr === null) return refusedPass(io, EXIT_CODES.REMOTE_FAILURE, 'the PR could not be read');
|
|
1513
1770
|
|
|
1514
1771
|
const trust = resolveTrust(io, pr, args.approval);
|
|
1515
1772
|
const record = findTrustedRecord(pr, trust);
|
|
@@ -1520,12 +1777,12 @@ function runVerify(io, args, deps) {
|
|
|
1520
1777
|
// trusted record's.
|
|
1521
1778
|
const fromFile = evidence !== null && evidence.plan !== null;
|
|
1522
1779
|
const plan = fromFile ? /** @type {any} */ (evidence).plan : bodyPlan(pr.body);
|
|
1523
|
-
if (plan === null) return
|
|
1780
|
+
if (plan === null) return refusedPass(io, EXIT_CODES.INPUT_UNUSABLE, 'the PR body test-plan block cannot serve as the plan');
|
|
1524
1781
|
if (!fromFile && !coversRecord(plan, record)) {
|
|
1525
|
-
return
|
|
1782
|
+
return refusedPass(io, EXIT_CODES.INPUT_UNUSABLE, 'the PR body test-plan block drops a TP the trusted record carries');
|
|
1526
1783
|
}
|
|
1527
1784
|
const inputs = tpInputs(plan, evidence, record, fromFile);
|
|
1528
|
-
if (inputs === null) return
|
|
1785
|
+
if (inputs === null) return refusedPass(io, EXIT_CODES.OUTPUT_GATE_REFUSED, 'a TP line failed its grammar');
|
|
1529
1786
|
|
|
1530
1787
|
const repo = prepareRepo(io, pr);
|
|
1531
1788
|
/** @type {RunMemo} */
|
|
@@ -1539,7 +1796,7 @@ function runVerify(io, args, deps) {
|
|
|
1539
1796
|
: record.kind === 'record' ? record.exceptions : [];
|
|
1540
1797
|
const key = PE.dedupeKey({ records, exceptions }, sha256);
|
|
1541
1798
|
const tally = PE.tally(records.map(r => r.state));
|
|
1542
|
-
if (!key.ok || !tally.ok) return
|
|
1799
|
+
if (!key.ok || !tally.ok) return refusedPass(io, EXIT_CODES.OUTPUT_GATE_REFUSED, 'the records failed their grammar');
|
|
1543
1800
|
|
|
1544
1801
|
const mode = publicationMode(args.publication);
|
|
1545
1802
|
const ev = {
|
|
@@ -1550,8 +1807,9 @@ function runVerify(io, args, deps) {
|
|
|
1550
1807
|
exceptions,
|
|
1551
1808
|
};
|
|
1552
1809
|
const body = bodyUpdate(io, plan, ev, pr.body);
|
|
1553
|
-
const
|
|
1554
|
-
|
|
1810
|
+
const target = editTarget(pr);
|
|
1811
|
+
const comment = commentUpdate(plan, ev, mode, target);
|
|
1812
|
+
if (body === null || comment === null) return refusedPass(io, EXIT_CODES.OUTPUT_GATE_REFUSED, 'the block or the comment failed to render');
|
|
1555
1813
|
|
|
1556
1814
|
const line = gateEvidenceLine(deps, {
|
|
1557
1815
|
pr: pr.number,
|
|
@@ -1565,12 +1823,22 @@ function runVerify(io, args, deps) {
|
|
|
1565
1823
|
posted: comment.posted,
|
|
1566
1824
|
body: body.changed ? 'changed' : 'same',
|
|
1567
1825
|
}, plan.tps.length);
|
|
1568
|
-
if (line === null) return
|
|
1826
|
+
if (line === null) return refusedPass(io, EXIT_CODES.OUTPUT_GATE_REFUSED, 'the EVIDENCE line failed its gate');
|
|
1569
1827
|
|
|
1570
|
-
if (!writeOutputs(args, pr.body, body.blockText, comment.text, staleOutText(inputs, records))) {
|
|
1571
|
-
return
|
|
1828
|
+
if (!writeOutputs(args, pr.body, body.blockText, comment.text, staleOutText(inputs, records, fromFile))) {
|
|
1829
|
+
return refusedPass(io, EXIT_CODES.WRITE_FAILED, 'an output file could not be written');
|
|
1572
1830
|
}
|
|
1573
|
-
return { code: EXIT_CODES.OK, stdout: line + '\n' };
|
|
1831
|
+
return { outcome: { code: EXIT_CODES.OK, stdout: line + '\n' }, target };
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
/**
|
|
1835
|
+
* @param {Io} io
|
|
1836
|
+
* @param {Extract<ParsedArgs, { kind: 'verify' }>} args
|
|
1837
|
+
* @param {MainDeps} deps
|
|
1838
|
+
* @returns {Outcome}
|
|
1839
|
+
*/
|
|
1840
|
+
function runVerify(io, args, deps) {
|
|
1841
|
+
return verifyPass(io, args, deps).outcome;
|
|
1574
1842
|
}
|
|
1575
1843
|
|
|
1576
1844
|
/**
|
|
@@ -1698,7 +1966,7 @@ function runCheck(io, args) {
|
|
|
1698
1966
|
let result;
|
|
1699
1967
|
if (args.what === 'tp') result = PE.parsePlan(sectionOrWhole(text, 'testPlan'));
|
|
1700
1968
|
else if (args.what === 'exceptions') result = PE.parseExceptions(sectionOrWhole(text, 'exceptions'));
|
|
1701
|
-
else if (args.what === 'wave') result = PE.parseWaveBlock(text);
|
|
1969
|
+
else if (args.what === 'wave') result = PE.parseWaveBlock(text, { linksOnly: args.linksOnly });
|
|
1702
1970
|
else {
|
|
1703
1971
|
const b = PE.parseBlock(text);
|
|
1704
1972
|
// R7 pastes only the creation form: TP lines, none ticked.
|
|
@@ -1749,13 +2017,22 @@ const REASON_RE = /^[a-z0-9][a-z0-9 -]{0,59}$/;
|
|
|
1749
2017
|
/** What a `publish` result prints when it could not run at all (exit 2). */
|
|
1750
2018
|
const EVIDENCE_UNAVAILABLE_LINE = 'TRACEABILITY: DEGRADED (evidence unavailable)';
|
|
1751
2019
|
|
|
2020
|
+
/** What a `publish` result prints when the machine manifest does not say compliance is enabled (exit 2). */
|
|
2021
|
+
const COMPLIANCE_OFF_LINE = 'TRACEABILITY: DEGRADED (compliance off)';
|
|
2022
|
+
|
|
2023
|
+
/** What a `publish` result prints when the machine manifest cannot be read (exit 2). */
|
|
2024
|
+
const COMPLIANCE_UNREADABLE_LINE = 'TRACEABILITY: DEGRADED (compliance state unreadable)';
|
|
2025
|
+
|
|
2026
|
+
/** Every line a `publish` that did not run may print. The boundary admits each one with exit 2 only. */
|
|
2027
|
+
const PUBLISH_REFUSAL_LINES = Object.freeze([EVIDENCE_UNAVAILABLE_LINE, COMPLIANCE_OFF_LINE, COMPLIANCE_UNREADABLE_LINE]);
|
|
2028
|
+
|
|
1752
2029
|
/**
|
|
1753
2030
|
* @typedef {{ status: string, reason: string | null }} Part
|
|
1754
2031
|
* One half of the `publish` answer: a closed status, or `DEGRADED` with its reason.
|
|
1755
2032
|
*/
|
|
1756
2033
|
|
|
1757
2034
|
const BODY_STATUSES = Object.freeze(['EDITED', 'UNCHANGED', 'SKIPPED']);
|
|
1758
|
-
const COMMENT_STATUSES = Object.freeze(['POSTED', 'SKIPPED', 'OFF']);
|
|
2035
|
+
const COMMENT_STATUSES = Object.freeze(['POSTED', 'SKIPPED', 'OFF', 'EDITED']);
|
|
1759
2036
|
|
|
1760
2037
|
/**
|
|
1761
2038
|
* @param {string} status
|
|
@@ -1900,6 +2177,18 @@ function ghWrite(io, args) {
|
|
|
1900
2177
|
return { ok: r.ok, serverError: isServerError(r) };
|
|
1901
2178
|
}
|
|
1902
2179
|
|
|
2180
|
+
/**
|
|
2181
|
+
* The DEGRADED part for a gh write that failed: the 5xx phrase after a server error,
|
|
2182
|
+
* else the phrase of the failed step. It runs no gh call.
|
|
2183
|
+
*
|
|
2184
|
+
* @param {{ ok: boolean, serverError: boolean }} result what ghWrite returned
|
|
2185
|
+
* @param {string} reason a fixed phrase from this file
|
|
2186
|
+
* @returns {Part}
|
|
2187
|
+
*/
|
|
2188
|
+
function writeFailedPart(result, reason) {
|
|
2189
|
+
return degradedPart(result.serverError ? '5xx on update-pr-evidence' : reason);
|
|
2190
|
+
}
|
|
2191
|
+
|
|
1903
2192
|
/**
|
|
1904
2193
|
* The context for the writes: a gh budget and a deadline of their own, the same
|
|
1905
2194
|
* notes. The throttle flag carries over, so a rate limit that verify hit stops the
|
|
@@ -1938,38 +2227,152 @@ function publishBody(io, pr, fields, files) {
|
|
|
1938
2227
|
if (outcome === 'conflict') return okPart('SKIPPED');
|
|
1939
2228
|
if (outcome !== 'ok' && outcome !== 'resplice') return degradedPart('splice ' + outcome);
|
|
1940
2229
|
const edit = ghWrite(io, ['pr', 'edit', String(pr), '--body-file', files.body]);
|
|
1941
|
-
if (!edit.ok) return
|
|
2230
|
+
if (!edit.ok) return writeFailedPart(edit, 'gh pr edit failed');
|
|
1942
2231
|
const back = runReadback(io, { kind: 'readback', pr, expect: files.body });
|
|
1943
2232
|
return back.code === EXIT_CODES.OK && back.stdout === 'READBACK ok\n' ? okPart('EDITED') : degradedPart('body read-back mismatch');
|
|
1944
2233
|
}
|
|
1945
2234
|
|
|
1946
2235
|
/**
|
|
1947
|
-
*
|
|
1948
|
-
*
|
|
2236
|
+
* Whether the comment just edited now reads back as the scrubbed text: one GET of it
|
|
2237
|
+
* as a JSON string, compared after one trailing newline is normalised on each side.
|
|
2238
|
+
* The edit itself runs silent, so this is the only read of the comment. Any failure
|
|
2239
|
+
* to read or parse is "no". Neither the comment nor gh's stderr is printed.
|
|
2240
|
+
*
|
|
2241
|
+
* @param {Io} io the write-stage context
|
|
2242
|
+
* @param {string} id a comment id commentIdOf produced
|
|
2243
|
+
* @param {string} expected the scrubbed comment text (never the unscrubbed file)
|
|
2244
|
+
* @returns {boolean}
|
|
2245
|
+
*/
|
|
2246
|
+
function commentReadsBack(io, id, expected) {
|
|
2247
|
+
const r = gh(io, ['api', 'repos/{owner}/{repo}/issues/comments/' + commentIdArg(id), '--jq', '.body | @json'], COMMENT_READ_MAX_BUFFER);
|
|
2248
|
+
if (!r.ok) return false;
|
|
2249
|
+
try {
|
|
2250
|
+
const body = JSON.parse(stripOneNewline(r.stdout.toString('utf8')));
|
|
2251
|
+
return typeof body === 'string' && stripOneNewline(body) === stripOneNewline(expected);
|
|
2252
|
+
} catch (_) {
|
|
2253
|
+
return false;
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
/**
|
|
2258
|
+
* The comment half: the evidence comment, scrubbed (D11) and re-measured after the
|
|
2259
|
+
* scrub. When the viewer's own evidence comment is a safe target (no strict marker
|
|
2260
|
+
* from another login follows it), that comment is edited in place (one PATCH of the
|
|
2261
|
+
* scrubbed file) and read back against it. Otherwise a new comment is posted once.
|
|
2262
|
+
* A comment that already carries this head and key is skipped. An own target with no
|
|
2263
|
+
* readable id is DEGRADED before the scrub, with no write. A failed edit or
|
|
2264
|
+
* read-back is a DEGRADED result and never falls back to a post. Nothing is deleted.
|
|
1949
2265
|
*
|
|
1950
2266
|
* @param {Io} io the write-stage context
|
|
1951
2267
|
* @param {number} pr
|
|
1952
2268
|
* @param {{ posted: string }} fields the EVIDENCE line's fields
|
|
1953
2269
|
* @param {{ commentRaw: string, comment: string }} files
|
|
2270
|
+
* @param {EditTarget | null} target the viewer's newest evidence comment in the snapshot
|
|
2271
|
+
* verify read, or null when there is none
|
|
1954
2272
|
* @returns {Part}
|
|
1955
2273
|
*/
|
|
1956
|
-
function publishComment(io, pr, fields, files) {
|
|
2274
|
+
function publishComment(io, pr, fields, files, target) {
|
|
1957
2275
|
if (fields.posted === 'yes') return okPart('SKIPPED');
|
|
1958
2276
|
if (fields.posted === 'n/a') return okPart('OFF');
|
|
2277
|
+
const edit = target !== null && target.editable;
|
|
2278
|
+
if (edit && target.id === '') return degradedPart('comment id unreadable');
|
|
1959
2279
|
if (!scrub(io, files.commentRaw, files.comment)) return degradedPart('redaction unavailable');
|
|
1960
2280
|
const text = readTextFile(files.comment);
|
|
1961
2281
|
if (text === null || text === '') return degradedPart('redaction unavailable');
|
|
1962
2282
|
if (text.length > COMMENT_POST_MAX_CHARS) return degradedPart('comment over size cap');
|
|
2283
|
+
if (edit) {
|
|
2284
|
+
const patch = ghWrite(io, ['api', '--method', 'PATCH',
|
|
2285
|
+
'repos/{owner}/{repo}/issues/comments/' + commentIdArg(target.id),
|
|
2286
|
+
'-F', 'body=@' + files.comment, '--silent']);
|
|
2287
|
+
if (!patch.ok) return writeFailedPart(patch, 'comment edit failed');
|
|
2288
|
+
return commentReadsBack(io, target.id, text) ? okPart('EDITED') : degradedPart('comment read-back mismatch');
|
|
2289
|
+
}
|
|
1963
2290
|
const post = ghWrite(io, ['pr', 'comment', String(pr), '--body-file', files.comment]);
|
|
1964
|
-
|
|
1965
|
-
|
|
2291
|
+
return post.ok ? okPart('POSTED') : writeFailedPart(post, 'gh pr comment failed');
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
/**
|
|
2295
|
+
* The machine manifest, ~/.devflow/manifest.json, or null when the home directory is
|
|
2296
|
+
* not absolute (a relative one would resolve against the repository). No environment
|
|
2297
|
+
* variable relocates it, and no repository file stands in for it.
|
|
2298
|
+
*
|
|
2299
|
+
* @returns {string | null}
|
|
2300
|
+
*/
|
|
2301
|
+
function machineManifestPath() {
|
|
2302
|
+
let home;
|
|
2303
|
+
try {
|
|
2304
|
+
home = os.homedir();
|
|
2305
|
+
} catch (_) {
|
|
2306
|
+
return null;
|
|
2307
|
+
}
|
|
2308
|
+
return typeof home === 'string' && path.isAbsolute(home) ? path.join(home, '.devflow', 'manifest.json') : null;
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
/**
|
|
2312
|
+
* @param {unknown} value
|
|
2313
|
+
* @returns {value is Record<string, unknown>}
|
|
2314
|
+
*/
|
|
2315
|
+
function isRecord(value) {
|
|
2316
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
/**
|
|
2320
|
+
* The compliance switch of the machine manifest, read with this file's regular-file
|
|
2321
|
+
* rules (a link is followed, as every other reader of the machine manifest does). The
|
|
2322
|
+
* accepted shape is the one the CLI keeps, `features.compliance` as
|
|
2323
|
+
* `{ enabled: boolean, frameworks: string[] }`; a manifest from before compliance
|
|
2324
|
+
* existed has no such key and reads as off. A value in any other shape is `unreadable`,
|
|
2325
|
+
* where the CLI heals it to off: this guard only lets a publish through on a record it
|
|
2326
|
+
* can trust.
|
|
2327
|
+
*
|
|
2328
|
+
* @param {string | null} manifestPath
|
|
2329
|
+
* @returns {'enabled' | 'off' | 'unreadable'}
|
|
2330
|
+
*/
|
|
2331
|
+
function machineComplianceState(manifestPath) {
|
|
2332
|
+
if (manifestPath === null) return 'unreadable';
|
|
2333
|
+
const text = readTextFile(manifestPath);
|
|
2334
|
+
if (text === null) return 'unreadable';
|
|
2335
|
+
/** @type {unknown} */
|
|
2336
|
+
let manifest;
|
|
2337
|
+
try {
|
|
2338
|
+
manifest = JSON.parse(text);
|
|
2339
|
+
} catch (_) {
|
|
2340
|
+
return 'unreadable';
|
|
2341
|
+
}
|
|
2342
|
+
if (!isRecord(manifest) || !isRecord(manifest.features)) return 'unreadable';
|
|
2343
|
+
const compliance = manifest.features.compliance;
|
|
2344
|
+
if (compliance === undefined) return 'off';
|
|
2345
|
+
if (!isRecord(compliance) || typeof compliance.enabled !== 'boolean') return 'unreadable';
|
|
2346
|
+
if (!Array.isArray(compliance.frameworks) || !compliance.frameworks.every(f => typeof f === 'string')) return 'unreadable';
|
|
2347
|
+
return compliance.enabled ? 'enabled' : 'off';
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
/**
|
|
2351
|
+
* The publish guard: with compliance off, or its state unreadable, `publish` ends here,
|
|
2352
|
+
* before a work directory is made and before any gh call. It reads the machine manifest
|
|
2353
|
+
* under the user's home folder and nothing else, so no repository file can switch it on.
|
|
2354
|
+
* It ignores the devflow folder override. It returns its outcome and never throws: the
|
|
2355
|
+
* catch in main turns a throw into "evidence unavailable", which would hide the reason.
|
|
2356
|
+
*
|
|
2357
|
+
* @param {Io} io
|
|
2358
|
+
* @param {MainDeps} deps
|
|
2359
|
+
* @returns {Outcome | null} the refusal, or null when compliance is enabled
|
|
2360
|
+
*/
|
|
2361
|
+
function publishRefusal(io, deps) {
|
|
2362
|
+
const state = machineComplianceState(typeof deps.manifestPath === 'string' ? deps.manifestPath : machineManifestPath());
|
|
2363
|
+
if (state === 'enabled') return null;
|
|
2364
|
+
io.stderr('verify-evidence: publish: '
|
|
2365
|
+
+ (state === 'off' ? 'compliance is off on this machine' : 'the machine compliance state could not be read') + '\n');
|
|
2366
|
+
return { code: EXIT_CODES.INPUT_UNUSABLE, stdout: (state === 'off' ? COMPLIANCE_OFF_LINE : COMPLIANCE_UNREADABLE_LINE) + '\n' };
|
|
1966
2367
|
}
|
|
1967
2368
|
|
|
1968
2369
|
/**
|
|
1969
2370
|
* The whole update-pr-evidence operation, in the order the operation always had:
|
|
1970
|
-
* verify; only on `body:changed` scrub, splice, `gh pr edit`,
|
|
1971
|
-
* `posted:no` scrub
|
|
1972
|
-
*
|
|
2371
|
+
* the compliance guard; verify; only on `body:changed` scrub, splice, `gh pr edit`,
|
|
2372
|
+
* read back; only on `posted:no` scrub, then edit the viewer's own evidence comment in
|
|
2373
|
+
* place and read it back, or post a new one when there is none to edit or a newer
|
|
2374
|
+
* evidence comment from another login follows it. The work files live in one private
|
|
2375
|
+
* directory this call makes and removes.
|
|
1973
2376
|
*
|
|
1974
2377
|
* @param {Io} io
|
|
1975
2378
|
* @param {Extract<ParsedArgs, { kind: 'publish' }>} args
|
|
@@ -1977,6 +2380,8 @@ function publishComment(io, pr, fields, files) {
|
|
|
1977
2380
|
* @returns {Outcome}
|
|
1978
2381
|
*/
|
|
1979
2382
|
function runPublish(io, args, deps) {
|
|
2383
|
+
const refused = publishRefusal(io, deps);
|
|
2384
|
+
if (refused !== null) return refused;
|
|
1980
2385
|
/** @type {string} */
|
|
1981
2386
|
let dir;
|
|
1982
2387
|
try {
|
|
@@ -2000,18 +2405,18 @@ function runPublish(io, args, deps) {
|
|
|
2000
2405
|
commentRaw: file('comment.raw'),
|
|
2001
2406
|
comment: file('comment'),
|
|
2002
2407
|
};
|
|
2003
|
-
const verified =
|
|
2408
|
+
const verified = verifyPass(io, {
|
|
2004
2409
|
kind: 'verify', pr: args.pr, publication: args.publication, evidence: args.evidence, state,
|
|
2005
2410
|
blockOut: files.notesRaw, commentOut: files.commentRaw, staleOut: null, approval: false,
|
|
2006
2411
|
}, deps);
|
|
2007
|
-
if (verified.code !== EXIT_CODES.OK) return evidenceUnavailable(io, 'verify did not answer');
|
|
2008
|
-
const line = stripOneNewline(verified.stdout);
|
|
2412
|
+
if (verified.outcome.code !== EXIT_CODES.OK) return evidenceUnavailable(io, 'verify did not answer');
|
|
2413
|
+
const line = stripOneNewline(verified.outcome.stdout);
|
|
2009
2414
|
const parsed = PE.parseEvidenceLine(line);
|
|
2010
2415
|
if (!parsed.ok || parsed.value.pr !== args.pr) return evidenceUnavailable(io, 'verify answered something else');
|
|
2011
2416
|
|
|
2012
2417
|
const stage = writeStageIo(io);
|
|
2013
2418
|
const body = publishBody(stage, args.pr, parsed.value, files);
|
|
2014
|
-
const comment = publishComment(stage, args.pr, parsed.value, files);
|
|
2419
|
+
const comment = publishComment(stage, args.pr, parsed.value, files, verified.target);
|
|
2015
2420
|
return { code: EXIT_CODES.OK, stdout: renderPublishBlock(line, body, comment) };
|
|
2016
2421
|
} finally {
|
|
2017
2422
|
try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) { /* the OS clears its temp directory */ }
|
|
@@ -2162,21 +2567,28 @@ function parseJsonOrNull(text) {
|
|
|
2162
2567
|
}
|
|
2163
2568
|
|
|
2164
2569
|
/**
|
|
2165
|
-
* The PR's review decision
|
|
2166
|
-
*
|
|
2167
|
-
* ladder never lets that reach READY.
|
|
2570
|
+
* The PR's review decision, merge state and head, from one `gh pr view` read. The
|
|
2571
|
+
* decision answers `""` or null when no rule asks for a review; a string outside the
|
|
2572
|
+
* three documented values reads UNRECOGNISED, and the ladder never lets that reach READY.
|
|
2573
|
+
* `mergeable` is the string GitHub printed, or null when the key is absent or not a
|
|
2574
|
+
* string; `head` is the PR head when it is 40 lowercase hex, else null. Neither makes the
|
|
2575
|
+
* decision unreadable: the CI read treats them as unknown and never as a pass.
|
|
2168
2576
|
*
|
|
2169
2577
|
* @param {Io} io
|
|
2170
2578
|
* @param {number} pr
|
|
2171
|
-
* @returns {Read<string | null>}
|
|
2579
|
+
* @returns {Read<{ decision: string | null, mergeable: string | null, head: string | null }>}
|
|
2172
2580
|
*/
|
|
2173
|
-
function
|
|
2174
|
-
const r = gh(io, ['pr', 'view', String(pr), '--json', 'reviewDecision'], LINE_MAX_BUFFER);
|
|
2581
|
+
function readPrView(io, pr) {
|
|
2582
|
+
const r = gh(io, ['pr', 'view', String(pr), '--json', 'reviewDecision,mergeable,headRefOid'], LINE_MAX_BUFFER);
|
|
2175
2583
|
if (!r.ok) return { ok: false, reason: readFailure(r) };
|
|
2176
|
-
const
|
|
2177
|
-
|
|
2584
|
+
const fields = obj(parseJsonOrNull(r.stdout.toString('utf8')));
|
|
2585
|
+
const value = fields.reviewDecision;
|
|
2586
|
+
const mergeable = typeof fields.mergeable === 'string' ? fields.mergeable : null;
|
|
2587
|
+
const head = typeof fields.headRefOid === 'string' && SHA40_RE.test(fields.headRefOid) ? fields.headRefOid : null;
|
|
2588
|
+
if (value === null || value === '') return { ok: true, value: { decision: null, mergeable, head } };
|
|
2178
2589
|
if (typeof value !== 'string') return { ok: false, reason: 'review decision unreadable' };
|
|
2179
|
-
|
|
2590
|
+
const decision = ['APPROVED', 'CHANGES_REQUESTED', 'REVIEW_REQUIRED'].includes(value) ? value : 'UNRECOGNISED';
|
|
2591
|
+
return { ok: true, value: { decision, mergeable, head } };
|
|
2180
2592
|
}
|
|
2181
2593
|
|
|
2182
2594
|
/**
|
|
@@ -2215,49 +2627,105 @@ function readThreads(io, pr) {
|
|
|
2215
2627
|
return { ok: true, value: { unresolved, approximate: total > THREADS_PAGE } };
|
|
2216
2628
|
}
|
|
2217
2629
|
|
|
2630
|
+
/** CI that could not be established: the ladder reads it as `status unknown`, never READY. */
|
|
2631
|
+
const CI_INDETERMINATE = Object.freeze({ status: 'INDETERMINATE', failing: '-' });
|
|
2632
|
+
|
|
2218
2633
|
/**
|
|
2219
|
-
* D-READINESS-
|
|
2220
|
-
*
|
|
2221
|
-
*
|
|
2222
|
-
*
|
|
2634
|
+
* D-READINESS-WORKFLOWS: whether the repository has an active workflow file, by ci-wait's
|
|
2635
|
+
* one read-only call and parser. A call that fails or is refused, and a reply the parser
|
|
2636
|
+
* cannot read, give null: not knowing, never "no workflow file".
|
|
2637
|
+
*
|
|
2638
|
+
* @param {Io} io
|
|
2639
|
+
* @returns {boolean | null}
|
|
2640
|
+
*/
|
|
2641
|
+
function probeWorkflows(io) {
|
|
2642
|
+
const r = gh(io, CW.WORKFLOWS_ARGS, READINESS_MAX_BUFFER);
|
|
2643
|
+
return r.ok ? CW.parseActiveWorkflows(r.stdout.toString('utf8')) : null;
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
/**
|
|
2647
|
+
* An empty check list: ci-wait's merge gate decides (D-CI-WORKFLOWS). The workflow list
|
|
2648
|
+
* is asked only when the gate asks for it, so a PR that cannot be NO_CI costs no call. A
|
|
2649
|
+
* gate answer other than keep is INDETERMINATE.
|
|
2650
|
+
*
|
|
2651
|
+
* @param {Io} io
|
|
2652
|
+
* @param {string | null} mergeable
|
|
2653
|
+
* @returns {{ status: string, failing: string }}
|
|
2654
|
+
*/
|
|
2655
|
+
function readEmptyCi(io, mergeable) {
|
|
2656
|
+
const first = CW.mergeGate('NO_CI', mergeable);
|
|
2657
|
+
const gate = first === 'probe' ? CW.mergeGate('NO_CI', mergeable, probeWorkflows(io)) : first;
|
|
2658
|
+
return gate === 'keep' ? { status: 'NO_CI', failing: '-' } : CI_INDETERMINATE;
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2661
|
+
/**
|
|
2662
|
+
* D-READINESS-CLASSIFIER: the CI status, read once. The checks come from `gh pr checks`
|
|
2663
|
+
* over name,state,bucket,workflow, with check-ci-status's exit rule (exit 0 or 8 is a
|
|
2664
|
+
* result; any other failure is INDETERMINATE). The rows come from ci-wait's parser, and the
|
|
2665
|
+
* status from ci-wait's classifier (D-CI-WORKFLOW-RULE) and merge gate (D-CI-MERGE-STATE,
|
|
2666
|
+
* D-CI-WORKFLOWS), where the rules are written, so the two scripts cannot disagree. A
|
|
2667
|
+
* conflicting PR reads INDETERMINATE before any checks call, and "no checks reported" or
|
|
2668
|
+
* `[]` goes to readEmptyCi. An unknown merge state, a failed workflow-list call and an
|
|
2669
|
+
* unreadable reply are INDETERMINATE, never DEGRADED. Failing names are sanitized and
|
|
2670
|
+
* bounded by ci-wait's own formatter. PASSING does not prove that the test workflow ran by
|
|
2671
|
+
* name.
|
|
2223
2672
|
*
|
|
2224
2673
|
* @param {Io} io
|
|
2225
2674
|
* @param {number} pr
|
|
2675
|
+
* @param {string | null} mergeable GitHub's merge state of the PR, from readPrView
|
|
2226
2676
|
* @returns {{ status: string, failing: string }}
|
|
2227
2677
|
*/
|
|
2228
|
-
function readCi(io, pr) {
|
|
2229
|
-
|
|
2230
|
-
const
|
|
2231
|
-
if (!answered(r)) return
|
|
2678
|
+
function readCi(io, pr, mergeable) {
|
|
2679
|
+
if (CW.isConflicting(mergeable)) return CI_INDETERMINATE;
|
|
2680
|
+
const r = gh(io, ['pr', 'checks', String(pr), '--json', 'name,state,bucket,workflow'], READINESS_MAX_BUFFER);
|
|
2681
|
+
if (!answered(r)) return CI_INDETERMINATE;
|
|
2232
2682
|
if (r.status !== 0 && r.status !== 8) {
|
|
2233
2683
|
if (/Could not resolve to a PullRequest/.test(r.stderr)) return { status: 'NO_PR', failing: '-' };
|
|
2234
|
-
return /no checks reported/i.test(r.stderr) ?
|
|
2235
|
-
}
|
|
2236
|
-
const rows = parseJsonOrNull(r.stdout.toString('utf8'));
|
|
2237
|
-
if (!Array.isArray(rows) || rows.length > READINESS_MAX_CHECKS) return indeterminate;
|
|
2238
|
-
if (rows.length === 0) return { status: 'NO_CI', failing: '-' };
|
|
2239
|
-
const checks = [];
|
|
2240
|
-
for (const row of rows) {
|
|
2241
|
-
const x = obj(row);
|
|
2242
|
-
if (typeof x.name !== 'string' || typeof x.bucket !== 'string') return indeterminate;
|
|
2243
|
-
checks.push({ name: x.name, bucket: x.bucket });
|
|
2684
|
+
return /no checks reported/i.test(r.stderr) ? readEmptyCi(io, mergeable) : CI_INDETERMINATE;
|
|
2244
2685
|
}
|
|
2686
|
+
const checks = CW.parseChecks(r.stdout.toString('utf8'));
|
|
2687
|
+
if (checks === null) return CI_INDETERMINATE;
|
|
2688
|
+
if (checks.length === 0) return readEmptyCi(io, mergeable);
|
|
2245
2689
|
const c = CW.classifyChecks(checks);
|
|
2690
|
+
if (CW.mergeGate(c.status, mergeable, null) !== 'keep') return CI_INDETERMINATE;
|
|
2246
2691
|
return { status: c.status, failing: CW.formatNames(c.failing) };
|
|
2247
2692
|
}
|
|
2248
2693
|
|
|
2694
|
+
/**
|
|
2695
|
+
* D-READINESS-HEAD: CI counts for one head. `gh pr checks` answers for whatever head the
|
|
2696
|
+
* PR has when it is called, and the evidence is read after it, so a push in between would
|
|
2697
|
+
* pair a CI result with evidence of another head. PASSING and NO_CI stand only when the
|
|
2698
|
+
* head read with the review decision, before the checks call, and the head the evidence
|
|
2699
|
+
* was read at are both 40 lowercase hex and equal; otherwise CI is INDETERMINATE. FAILING,
|
|
2700
|
+
* PENDING and the rest stay: they hold the ladder back on their own.
|
|
2701
|
+
*
|
|
2702
|
+
* @param {{ status: string, failing: string }} ci
|
|
2703
|
+
* @param {unknown} viewHead the head read before the checks call, or null
|
|
2704
|
+
* @param {unknown} evidenceHead the head the evidence was read at, or null when it is unknown
|
|
2705
|
+
* @returns {{ status: string, failing: string }}
|
|
2706
|
+
*/
|
|
2707
|
+
function bindCiHead(ci, viewHead, evidenceHead) {
|
|
2708
|
+
if (ci.status !== 'PASSING' && ci.status !== 'NO_CI') return ci;
|
|
2709
|
+
/** @param {unknown} head */
|
|
2710
|
+
const isHead = head => typeof head === 'string' && SHA40_RE.test(head);
|
|
2711
|
+
const bound = isHead(viewHead) && isHead(evidenceHead) && viewHead === evidenceHead;
|
|
2712
|
+
return bound ? ci : CI_INDETERMINATE;
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2249
2715
|
/**
|
|
2250
2716
|
* The test-plan evidence at the head, by `verify --approval`: known only on exit 0
|
|
2251
|
-
* with one EVIDENCE line for this PR (the old step 4's rule).
|
|
2717
|
+
* with one EVIDENCE line for this PR (the old step 4's rule). `evidence` is the local
|
|
2718
|
+
* evidence file `readiness --evidence` names, or null to read the PR's own record.
|
|
2252
2719
|
*
|
|
2253
2720
|
* @param {Io} io
|
|
2254
2721
|
* @param {number} pr
|
|
2722
|
+
* @param {string | null} evidence
|
|
2255
2723
|
* @param {MainDeps} deps
|
|
2256
2724
|
* @returns {any | null} the parsed EVIDENCE fields, or null when the evidence is unknown
|
|
2257
2725
|
*/
|
|
2258
|
-
function readEvidence(io, pr, deps) {
|
|
2726
|
+
function readEvidence(io, pr, evidence, deps) {
|
|
2259
2727
|
const verified = runVerify(io, {
|
|
2260
|
-
kind: 'verify', pr, publication: '', evidence
|
|
2728
|
+
kind: 'verify', pr, publication: '', evidence, state: null,
|
|
2261
2729
|
blockOut: null, commentOut: null, staleOut: null, approval: true,
|
|
2262
2730
|
}, deps);
|
|
2263
2731
|
if (verified.code !== EXIT_CODES.OK) return null;
|
|
@@ -2275,6 +2743,15 @@ function approvalText(require, evidence) {
|
|
|
2275
2743
|
return evidence !== null && evidence.approval === 'yes' ? 'yes' : 'no';
|
|
2276
2744
|
}
|
|
2277
2745
|
|
|
2746
|
+
/**
|
|
2747
|
+
* @param {string | null} decision the review decision from readPrView
|
|
2748
|
+
* @returns {string} the word the Details line prints for it
|
|
2749
|
+
*/
|
|
2750
|
+
function decisionLabel(decision) {
|
|
2751
|
+
if (decision === null) return 'none';
|
|
2752
|
+
return decision === 'UNRECOGNISED' ? 'unrecognised' : decision;
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2278
2755
|
/**
|
|
2279
2756
|
* A readiness answer that could not be reached: a DEGRADED block (exit 2) whose Details
|
|
2280
2757
|
* say `unavailable` for what was never read.
|
|
@@ -2301,22 +2778,28 @@ function readinessDegraded(pr, reason, known) {
|
|
|
2301
2778
|
* count it cannot establish, is DEGRADED; an unreadable CI status or unknown evidence
|
|
2302
2779
|
* is an input to the ladder, which never lets either reach READY.
|
|
2303
2780
|
*
|
|
2781
|
+
* @typedef {{ outcome: Outcome, head: string | null }} ReadinessPass
|
|
2782
|
+
* The answer, and the PR head its first read gave (null when that read gave none).
|
|
2783
|
+
*
|
|
2304
2784
|
* @param {Io} io
|
|
2305
2785
|
* @param {Extract<ParsedArgs, { kind: 'readiness' }>} args
|
|
2306
2786
|
* @param {MainDeps} deps
|
|
2307
|
-
* @returns {
|
|
2787
|
+
* @returns {ReadinessPass}
|
|
2308
2788
|
*/
|
|
2309
|
-
function
|
|
2789
|
+
function readinessPass(io, args, deps) {
|
|
2310
2790
|
const unreadApproval = approvalText(args.requireNonAuthor, null);
|
|
2311
|
-
const
|
|
2312
|
-
if (!
|
|
2313
|
-
const
|
|
2791
|
+
const view = readPrView(io, args.pr);
|
|
2792
|
+
if (!view.ok) return { outcome: readinessDegraded(args.pr, view.reason, { approval: unreadApproval }), head: null };
|
|
2793
|
+
const head = view.value.head;
|
|
2794
|
+
const reviewDecision = view.value.decision;
|
|
2795
|
+
const decisionText = decisionLabel(reviewDecision);
|
|
2314
2796
|
const threads = readThreads(io, args.pr);
|
|
2315
2797
|
if (!threads.ok) {
|
|
2316
|
-
return readinessDegraded(args.pr, threads.reason, { decision: decisionText, approval: unreadApproval });
|
|
2798
|
+
return { outcome: readinessDegraded(args.pr, threads.reason, { decision: decisionText, approval: unreadApproval }), head };
|
|
2317
2799
|
}
|
|
2318
|
-
const
|
|
2319
|
-
const fields = readEvidence(io, args.pr, deps);
|
|
2800
|
+
const ciRead = readCi(io, args.pr, view.value.mergeable);
|
|
2801
|
+
const fields = readEvidence(io, args.pr, args.evidence, deps);
|
|
2802
|
+
const ci = bindCiHead(ciRead, view.value.head, fields === null ? null : fields.head);
|
|
2320
2803
|
/** @type {EvidenceFacts | null} */
|
|
2321
2804
|
const evidence = fields === null ? null : {
|
|
2322
2805
|
total: fields.total,
|
|
@@ -2328,7 +2811,7 @@ function runReadiness(io, args, deps) {
|
|
|
2328
2811
|
const verdict = readinessVerdict({
|
|
2329
2812
|
unresolved: threads.value.unresolved,
|
|
2330
2813
|
approximate: threads.value.approximate,
|
|
2331
|
-
decision:
|
|
2814
|
+
decision: reviewDecision,
|
|
2332
2815
|
ci: ci.status,
|
|
2333
2816
|
failing: ci.failing,
|
|
2334
2817
|
evidence,
|
|
@@ -2348,7 +2831,95 @@ function runReadiness(io, args, deps) {
|
|
|
2348
2831
|
+ ' (VERIFIED-CI ' + evidence.verifiedCi + ', ATTESTED-LOCAL ' + evidence.attestedLocal + ')',
|
|
2349
2832
|
approval: approvalText(args.requireNonAuthor, evidence),
|
|
2350
2833
|
};
|
|
2351
|
-
return { code: EXIT_CODES.OK, stdout: renderReadinessBlock(block) };
|
|
2834
|
+
return { outcome: { code: EXIT_CODES.OK, stdout: renderReadinessBlock(block) }, head };
|
|
2835
|
+
}
|
|
2836
|
+
|
|
2837
|
+
/**
|
|
2838
|
+
* D-READINESS-OUT: `readiness --out <file>`. The file holds the READINESS line, then the
|
|
2839
|
+
* block exactly as it prints. The status in the line is the one the block's own parse
|
|
2840
|
+
* gives, so the line and the block cannot disagree. The head is the PR head the first
|
|
2841
|
+
* read gave, or `none`. The source is `local` when the plan was asked for from the
|
|
2842
|
+
* `--evidence` file and `pr` when it was asked for from the PR's own record; the line
|
|
2843
|
+
* ends with it as `source=<local|pr>`, and any other value is refused like a block that
|
|
2844
|
+
* does not parse. writeTextFile refuses a path that exists and is not a regular
|
|
2845
|
+
* file, a symlink included, so no link is followed. The command makes no gh write.
|
|
2846
|
+
*
|
|
2847
|
+
* One rule covers every outcome: a file from an earlier run never outlives this run.
|
|
2848
|
+
* runReadiness removes the earlier regular file before the first read; here, a block the
|
|
2849
|
+
* output gate would refuse is never written and is the gate's refusal, and a file that
|
|
2850
|
+
* cannot be written is the write refusal, with the regular file the write left removed.
|
|
2851
|
+
* Either way nothing is printed and no out file remains. A path that git tracks never
|
|
2852
|
+
* gets here: admitPaths refuses it first and leaves the file as it is.
|
|
2853
|
+
*
|
|
2854
|
+
* @param {Io} io
|
|
2855
|
+
* @param {string} file
|
|
2856
|
+
* @param {number} pr
|
|
2857
|
+
* @param {Outcome} outcome the answer readinessPass or readinessDegraded gave
|
|
2858
|
+
* @param {string | null} head
|
|
2859
|
+
* @param {string | undefined} source one of READINESS_SOURCES
|
|
2860
|
+
* @returns {Outcome}
|
|
2861
|
+
*/
|
|
2862
|
+
function writeReadinessOut(io, file, pr, outcome, head, source) {
|
|
2863
|
+
const block = parseReadinessBlock(outcome.stdout);
|
|
2864
|
+
if (block === null || !READINESS_SOURCES.includes(/** @type {string} */ (source))) {
|
|
2865
|
+
removeOutFile(file);
|
|
2866
|
+
return refuse(io, EXIT_CODES.OUTPUT_GATE_REFUSED, 'the readiness out file failed its grammar');
|
|
2867
|
+
}
|
|
2868
|
+
const line = [
|
|
2869
|
+
'READINESS', 'pr:' + pr, 'head:' + (head === null ? 'none' : head), 'status:' + block.status, 'source=' + source,
|
|
2870
|
+
].join(' ');
|
|
2871
|
+
if (!writeTextFile(file, line + '\n' + outcome.stdout)) {
|
|
2872
|
+
removeOutFile(file);
|
|
2873
|
+
return refuse(io, EXIT_CODES.WRITE_FAILED, 'an output file could not be written');
|
|
2874
|
+
}
|
|
2875
|
+
return outcome;
|
|
2876
|
+
}
|
|
2877
|
+
|
|
2878
|
+
/**
|
|
2879
|
+
* Remove the regular file an earlier run left at `file`. A path that names nothing, a
|
|
2880
|
+
* link, a FIFO or a directory is left as it is: writeTextFile creates the first and
|
|
2881
|
+
* refuses the rest. The lstat never follows a link. False when a regular file could not
|
|
2882
|
+
* be removed.
|
|
2883
|
+
*
|
|
2884
|
+
* @param {string} file
|
|
2885
|
+
* @returns {boolean}
|
|
2886
|
+
*/
|
|
2887
|
+
function removeOutFile(file) {
|
|
2888
|
+
try {
|
|
2889
|
+
const st = fs.lstatSync(file, { throwIfNoEntry: false });
|
|
2890
|
+
if (st !== undefined && st.isFile()) fs.unlinkSync(file);
|
|
2891
|
+
return true;
|
|
2892
|
+
} catch (_) {
|
|
2893
|
+
return false;
|
|
2894
|
+
}
|
|
2895
|
+
}
|
|
2896
|
+
|
|
2897
|
+
/**
|
|
2898
|
+
* Where the run asked for the test plan: the local evidence file `--evidence` names, or the
|
|
2899
|
+
* PR's own record. It follows the flag alone, so every status of one invocation names the
|
|
2900
|
+
* same source.
|
|
2901
|
+
*
|
|
2902
|
+
* @param {Extract<ParsedArgs, { kind: 'readiness' }>} args
|
|
2903
|
+
* @returns {'local' | 'pr'}
|
|
2904
|
+
*/
|
|
2905
|
+
function planSource(args) {
|
|
2906
|
+
return args.evidence === null ? 'pr' : 'local';
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
/**
|
|
2910
|
+
* @param {Io} io
|
|
2911
|
+
* @param {Extract<ParsedArgs, { kind: 'readiness' }>} args
|
|
2912
|
+
* @param {MainDeps} deps
|
|
2913
|
+
* @returns {Outcome}
|
|
2914
|
+
*/
|
|
2915
|
+
function runReadiness(io, args, deps) {
|
|
2916
|
+
// A file an earlier run left at the out path goes before the first read, so a run that
|
|
2917
|
+
// stops anywhere after this leaves no earlier answer behind.
|
|
2918
|
+
if (args.out !== null && !removeOutFile(args.out)) {
|
|
2919
|
+
return refuse(io, EXIT_CODES.WRITE_FAILED, 'an output file could not be written');
|
|
2920
|
+
}
|
|
2921
|
+
const pass = readinessPass(io, args, deps);
|
|
2922
|
+
return args.out === null ? pass.outcome : writeReadinessOut(io, args.out, args.pr, pass.outcome, pass.head, planSource(args));
|
|
2352
2923
|
}
|
|
2353
2924
|
|
|
2354
2925
|
// ---------------------------------------------------------------------------
|
|
@@ -2415,29 +2986,38 @@ function main(argv, deps) {
|
|
|
2415
2986
|
notes: new Set(),
|
|
2416
2987
|
stderr: typeof d.stderr === 'function' ? d.stderr : (text => { process.stderr.write(text); }),
|
|
2417
2988
|
};
|
|
2418
|
-
const
|
|
2419
|
-
if (
|
|
2989
|
+
const parsed = parseArgs(argv);
|
|
2990
|
+
if (parsed.kind === 'usage') {
|
|
2420
2991
|
io.stderr(USAGE + '\n');
|
|
2421
2992
|
return { code: EXIT_CODES.USAGE, stdout: '' };
|
|
2422
2993
|
}
|
|
2994
|
+
let args = parsed;
|
|
2423
2995
|
/** @type {Outcome} */
|
|
2424
2996
|
let outcome;
|
|
2425
2997
|
try {
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
else
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2998
|
+
const admitted = admitPaths(io, parsed);
|
|
2999
|
+
if ('outcome' in admitted) outcome = admitted.outcome;
|
|
3000
|
+
else {
|
|
3001
|
+
args = admitted.args;
|
|
3002
|
+
if (args.kind === 'check') outcome = runCheck(io, args);
|
|
3003
|
+
else if (args.kind === 'render') outcome = runRender(io, args);
|
|
3004
|
+
else if (args.kind === 'verify') outcome = runVerify(io, args, d);
|
|
3005
|
+
else if (args.kind === 'splice') outcome = runSplice(io, args);
|
|
3006
|
+
else if (args.kind === 'publish') outcome = runPublish(io, args, d);
|
|
3007
|
+
else if (args.kind === 'readiness') outcome = runReadiness(io, args, d);
|
|
3008
|
+
else outcome = runReadback(io, args);
|
|
3009
|
+
}
|
|
2433
3010
|
} catch (err) {
|
|
2434
3011
|
// An injected exec that throws, or a broken invariant (rev): no verdict. The two
|
|
2435
3012
|
// scripted operations still answer in their own DEGRADED form, since a caller
|
|
2436
3013
|
// prints whatever they print.
|
|
2437
3014
|
const label = 'internal error (' + errorLabel(err) + ')';
|
|
2438
3015
|
if (args.kind === 'publish') outcome = evidenceUnavailable(io, label);
|
|
2439
|
-
else if (args.kind === 'readiness')
|
|
2440
|
-
|
|
3016
|
+
else if (args.kind === 'readiness') {
|
|
3017
|
+
// The earlier out file is already gone (runReadiness), so the DEGRADED answer is the one file this run leaves.
|
|
3018
|
+
outcome = readinessDegraded(args.pr, 'internal error', {});
|
|
3019
|
+
if (args.out !== null) outcome = writeReadinessOut(io, args.out, args.pr, outcome, null, planSource(args));
|
|
3020
|
+
} else outcome = refuse(io, EXIT_CODES.REMOTE_FAILURE, label);
|
|
2441
3021
|
}
|
|
2442
3022
|
if (io.notes.size > 0) io.stderr('verify-evidence: notes: ' + [...io.notes].sort().join(',') + '\n');
|
|
2443
3023
|
return outcome;
|
|
@@ -2476,7 +3056,7 @@ function settleOutcome(outcome) {
|
|
|
2476
3056
|
if (o.stdout.startsWith('## PR Evidence\n')) {
|
|
2477
3057
|
return o.code === EXIT_CODES.OK && parsePublishBlock(o.stdout) !== null ? pass : failed;
|
|
2478
3058
|
}
|
|
2479
|
-
if (o.stdout ===
|
|
3059
|
+
if (PUBLISH_REFUSAL_LINES.some(line => o.stdout === line + '\n')) return o.code === EXIT_CODES.INPUT_UNUSABLE ? pass : failed;
|
|
2480
3060
|
if (o.stdout.startsWith('## Merge Readiness\n')) {
|
|
2481
3061
|
const block = parseReadinessBlock(o.stdout);
|
|
2482
3062
|
if (block === null) return failed;
|
|
@@ -2532,8 +3112,12 @@ module.exports = Object.freeze({
|
|
|
2532
3112
|
BODY_STATUSES,
|
|
2533
3113
|
COMMENT_STATUSES,
|
|
2534
3114
|
readinessVerdict,
|
|
3115
|
+
bindCiHead,
|
|
2535
3116
|
renderPublishBlock,
|
|
2536
3117
|
parsePublishBlock,
|
|
2537
3118
|
renderReadinessBlock,
|
|
2538
3119
|
parseReadinessBlock,
|
|
3120
|
+
writeReadinessOut,
|
|
3121
|
+
commentIdOf,
|
|
3122
|
+
editTarget,
|
|
2539
3123
|
});
|