devflow-kit 3.9.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 +111 -0
- package/README.md +1 -1
- package/dist/agents/code.md +6 -6
- package/dist/agents/fix.md +6 -6
- package/dist/agents/harden.md +7 -6
- package/dist/agents/refactor.md +6 -6
- 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 +12 -7
- package/dist/cli/commands/gate-level-prompts.js +39 -25
- package/dist/cli/commands/gates.js +5 -7
- package/dist/cli/commands/init.js +142 -138
- package/dist/cli/commands/install-report.js +34 -0
- package/dist/cli/commands/legacy-sweep.js +64 -0
- 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 +277 -51
- package/dist/commands/bug-analysis.md +69 -120
- package/dist/commands/dynamic-build.md +23 -23
- package/dist/commands/implement.md +46 -32
- package/dist/commands/release.md +1 -1
- package/dist/commands/resolve.md +35 -31
- package/dist/commands/self-review.md +8 -7
- 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/assets.js +126 -17
- package/dist/core/compliance-off-files.js +27 -0
- package/dist/core/curation-variants.js +460 -112
- 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 +65 -8
- 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 +6 -6
- package/dist/curation-off/agents/fix.md +6 -6
- package/dist/curation-off/agents/harden.md +7 -6
- package/dist/curation-off/agents/refactor.md +6 -6
- package/dist/curation-off/commands/bug-analysis.md +69 -119
- package/dist/curation-off/commands/dynamic-build.md +23 -23
- package/dist/curation-off/commands/implement.md +46 -32
- package/dist/curation-off/commands/release.md +1 -1
- package/dist/curation-off/commands/resolve.md +35 -31
- package/dist/curation-off/commands/self-review.md +8 -7
- package/dist/skills/git/references/pr/resolve-review-threads.md +1 -1
- 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/evaluate.md +1 -1
- package/src/assets/agents/git.mds +32 -0
- package/src/assets/agents/harden.mds +7 -6
- package/src/assets/agents/test.md +21 -12
- package/src/assets/agents/triage.mds +2 -0
- package/src/assets/agents/validate.md +10 -10
- package/src/assets/commands/_partials/_compliance.mds +5 -3
- package/src/assets/commands/_partials/_engine.mds +9 -8
- package/src/assets/commands/_partials/_publication.mds +10 -1
- package/src/assets/commands/bug-analysis.mds +80 -126
- package/src/assets/commands/code-review.mds +43 -0
- 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 +217 -32
- package/src/assets/commands/self-review.mds +8 -7
- package/src/assets/mds/git/_ops.mds +23 -0
- package/src/assets/mds/git/_ops_steps.mds +17 -0
- package/src/assets/mds/git/_pr.mds +1 -1
- package/src/assets/mds/git/_references.mds +6 -0
- package/src/assets/mds/tracker/_common.mds +4 -0
- package/src/assets/mds/tracker/_github.mds +42 -0
- package/src/assets/mds/tracker/_jira.mds +43 -0
- package/src/assets/mds/tracker/_linear.mds +43 -0
- package/src/assets/rules/quality.md +2 -0
- package/src/assets/scripts/ci-wait.cjs +204 -126
- 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/preamble +18 -8
- package/src/assets/scripts/hooks/session-start-orchestrator +4 -2
- package/src/assets/scripts/pr-evidence.cjs +41 -2
- package/src/assets/scripts/verify-evidence.cjs +408 -56
- 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/synthesize/bug-analysis.md +6 -2
- package/src/assets/skills/agent-references/references/validate/passed-alone.md +7 -7
- 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)
|
|
@@ -76,8 +105,8 @@ const childProcess = require('child_process');
|
|
|
76
105
|
|
|
77
106
|
const PE = require(path.join(__dirname, 'pr-evidence.cjs'));
|
|
78
107
|
// D-READINESS-CLASSIFIER: the CI classifier, the merge gate and the workflow-list probe
|
|
79
|
-
// are ci-wait's own, so the two scripts cannot disagree; only ci-wait
|
|
80
|
-
//
|
|
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
|
|
81
110
|
// `require.main === module`: requiring it starts nothing.
|
|
82
111
|
const CW = require(path.join(__dirname, 'ci-wait.cjs'));
|
|
83
112
|
|
|
@@ -158,6 +187,8 @@ const LINE_MAX_BUFFER = 4096;
|
|
|
158
187
|
const COMMENT_READ_MAX_BUFFER = 8 * 65536;
|
|
159
188
|
/** `gh pr checks` and the thread-count GraphQL answer: a thousand rows of names fit. */
|
|
160
189
|
const READINESS_MAX_BUFFER = 4 * 1024 * 1024;
|
|
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']);
|
|
161
192
|
/** 5,000 paths of 4,096 bytes overflow this; an overflow reads as an unresolved diff. */
|
|
162
193
|
const DIFF_MAX_BUFFER = 16 * 1024 * 1024;
|
|
163
194
|
const FETCH_MAX_BUFFER = 1024 * 1024;
|
|
@@ -194,6 +225,7 @@ const READBACK_LINE_RE = /^READBACK (?<outcome>ok|mismatch)$/;
|
|
|
194
225
|
|
|
195
226
|
const USAGE = [
|
|
196
227
|
'Usage: node verify-evidence.cjs check tp|block|exceptions|wave <file>',
|
|
228
|
+
' node verify-evidence.cjs check wave --links-only <file>',
|
|
197
229
|
' node verify-evidence.cjs render --plan <file>',
|
|
198
230
|
' node verify-evidence.cjs verify --pr <n> [--publication <mode>] [--evidence <file>]',
|
|
199
231
|
' [--state <dir>] [--block-out <file>] [--comment-out <file>] [--stale-out <file>] [--approval]',
|
|
@@ -201,6 +233,7 @@ const USAGE = [
|
|
|
201
233
|
' node verify-evidence.cjs readback --pr <n> --expect <file>',
|
|
202
234
|
' node verify-evidence.cjs publish --pr <n> [--publication auto|full|off|stub] [--evidence <file>]',
|
|
203
235
|
' node verify-evidence.cjs readiness --pr <n> [--require-non-author-approval true|false]',
|
|
236
|
+
' [--evidence <file>] [--out <file>]',
|
|
204
237
|
].join('\n');
|
|
205
238
|
|
|
206
239
|
// ---------------------------------------------------------------------------
|
|
@@ -236,8 +269,9 @@ const USAGE = [
|
|
|
236
269
|
* The one `gh pr view` answer, parsed. Lists are chronological, newest last.
|
|
237
270
|
*
|
|
238
271
|
* @typedef {{ exec?: ExecFn, cwd?: string, now?: () => number, stderr?: (text: string) => void,
|
|
239
|
-
* formatLine?: (fields: object) => unknown }} MainDeps
|
|
240
|
-
* 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.
|
|
241
275
|
*
|
|
242
276
|
* @typedef {{ code: number, stdout: string }} Outcome
|
|
243
277
|
*/
|
|
@@ -248,14 +282,15 @@ const USAGE = [
|
|
|
248
282
|
|
|
249
283
|
/**
|
|
250
284
|
* @typedef {{ kind: 'usage' }
|
|
251
|
-
* | { kind: 'check', what: 'tp' | 'block' | 'exceptions' | 'wave', file: string }
|
|
285
|
+
* | { kind: 'check', what: 'tp' | 'block' | 'exceptions' | 'wave', file: string, linksOnly: boolean }
|
|
252
286
|
* | { kind: 'render', plan: string }
|
|
253
287
|
* | { kind: 'verify', pr: number, publication: string, evidence: string | null, state: string | null,
|
|
254
288
|
* blockOut: string | null, commentOut: string | null, staleOut: string | null, approval: boolean }
|
|
255
289
|
* | { kind: 'splice', pr: number, state: string, block: string, out: string }
|
|
256
290
|
* | { kind: 'readback', pr: number, expect: string }
|
|
257
291
|
* | { kind: 'publish', pr: number, publication: string, evidence: string | null }
|
|
258
|
-
* | { 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
|
|
259
294
|
*/
|
|
260
295
|
|
|
261
296
|
/** @type {ParsedArgs} */
|
|
@@ -316,10 +351,12 @@ function parseArgs(argv) {
|
|
|
316
351
|
const sub = rest[0];
|
|
317
352
|
const tail = rest.slice(1);
|
|
318
353
|
if (sub === 'check') {
|
|
319
|
-
|
|
320
|
-
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];
|
|
321
358
|
if (typeof file !== 'string' || file === '' || file.startsWith('-') || file.length > MAX_ARG_CHARS) return USAGE_ARGS;
|
|
322
|
-
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 };
|
|
323
360
|
}
|
|
324
361
|
if (sub === 'render') {
|
|
325
362
|
const f = parseFlags(tail, { '--plan': 'value' });
|
|
@@ -371,13 +408,20 @@ function parseArgs(argv) {
|
|
|
371
408
|
return { kind: 'publish', pr, publication, evidence };
|
|
372
409
|
}
|
|
373
410
|
if (sub === 'readiness') {
|
|
374
|
-
const f = parseFlags(tail, {
|
|
411
|
+
const f = parseFlags(tail, {
|
|
412
|
+
'--pr': 'value', '--require-non-author-approval': 'value', '--evidence': 'value', '--out': 'value',
|
|
413
|
+
});
|
|
375
414
|
const pr = f === null ? null : parsePr(flagValue(f, '--pr'));
|
|
376
415
|
if (f === null || pr === null) return USAGE_ARGS;
|
|
377
416
|
const needed = flagValue(f, '--require-non-author-approval');
|
|
378
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;
|
|
379
423
|
// An absent flag is the ladder's "unrecognised": it can never read READY.
|
|
380
|
-
return { kind: 'readiness', pr, requireNonAuthor: needed === null ? 'unrecognised' : needed };
|
|
424
|
+
return { kind: 'readiness', pr, requireNonAuthor: needed === null ? 'unrecognised' : needed, evidence, out };
|
|
381
425
|
}
|
|
382
426
|
return USAGE_ARGS;
|
|
383
427
|
}
|
|
@@ -386,6 +430,24 @@ function parseArgs(argv) {
|
|
|
386
430
|
// Bounded file I/O
|
|
387
431
|
// ---------------------------------------------------------------------------
|
|
388
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
|
+
|
|
389
451
|
/**
|
|
390
452
|
* Read a regular file of at most MAX_FILE_BYTES as strict UTF-8, or null. The
|
|
391
453
|
* open adds O_NONBLOCK, so a FIFO put in a file's place cannot block, and the
|
|
@@ -427,19 +489,30 @@ function readTextFile(filePath) {
|
|
|
427
489
|
/**
|
|
428
490
|
* Write `text` to `filePath`, refusing anything that already exists and is not a
|
|
429
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.
|
|
430
495
|
*
|
|
431
496
|
* @param {string} filePath
|
|
432
497
|
* @param {string} text
|
|
433
498
|
* @returns {boolean}
|
|
434
499
|
*/
|
|
435
500
|
function writeTextFile(filePath, text) {
|
|
501
|
+
let fd;
|
|
436
502
|
try {
|
|
437
503
|
const st = fs.lstatSync(filePath, { throwIfNoEntry: false });
|
|
438
504
|
if (st !== undefined && !st.isFile()) return false;
|
|
439
|
-
|
|
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');
|
|
440
509
|
return true;
|
|
441
510
|
} catch (_) {
|
|
442
511
|
return false;
|
|
512
|
+
} finally {
|
|
513
|
+
if (fd !== undefined) {
|
|
514
|
+
try { fs.closeSync(fd); } catch (_) { /* the write already decided */ }
|
|
515
|
+
}
|
|
443
516
|
}
|
|
444
517
|
}
|
|
445
518
|
|
|
@@ -505,12 +578,13 @@ function refusedCall(reason) {
|
|
|
505
578
|
}
|
|
506
579
|
|
|
507
580
|
/**
|
|
508
|
-
* 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.
|
|
509
583
|
*
|
|
510
584
|
* @param {Io} io
|
|
511
585
|
* @param {string} file
|
|
512
586
|
* @param {readonly string[]} args
|
|
513
|
-
* @param {number} timeout
|
|
587
|
+
* @param {number | null} timeout
|
|
514
588
|
* @param {number} maxBuffer
|
|
515
589
|
* @returns {CallResult}
|
|
516
590
|
*/
|
|
@@ -519,7 +593,7 @@ function runCall(io, file, args, timeout, maxBuffer) {
|
|
|
519
593
|
cwd: io.cwd,
|
|
520
594
|
env: io.env,
|
|
521
595
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
522
|
-
timeout,
|
|
596
|
+
...(timeout === null ? {} : { timeout }),
|
|
523
597
|
maxBuffer,
|
|
524
598
|
windowsHide: true,
|
|
525
599
|
shell: false,
|
|
@@ -598,18 +672,103 @@ function gh(io, args, maxBuffer) {
|
|
|
598
672
|
|
|
599
673
|
/**
|
|
600
674
|
* Every git call: `-c core.fsmonitor=false` first, so no repository-configured
|
|
601
|
-
* 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.
|
|
602
677
|
*
|
|
603
678
|
* @param {Io} io
|
|
604
679
|
* @param {readonly string[]} args
|
|
605
|
-
* @param {number} timeout
|
|
680
|
+
* @param {number | null} timeout
|
|
606
681
|
* @param {number} maxBuffer
|
|
607
682
|
* @returns {CallResult}
|
|
608
683
|
*/
|
|
609
684
|
function git(io, args, timeout, maxBuffer) {
|
|
610
685
|
const left = remaining(io);
|
|
611
686
|
if (left === 0) { io.notes.add('deadline'); return refusedCall('deadline'); }
|
|
612
|
-
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 }) };
|
|
613
772
|
}
|
|
614
773
|
|
|
615
774
|
/**
|
|
@@ -1533,15 +1692,18 @@ function commentUpdate(plan, ev, mode, target) {
|
|
|
1533
1692
|
}
|
|
1534
1693
|
|
|
1535
1694
|
/**
|
|
1536
|
-
* --stale-out:
|
|
1537
|
-
* 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.
|
|
1538
1699
|
*
|
|
1539
1700
|
* @param {readonly TpInput[]} inputs
|
|
1540
1701
|
* @param {readonly { state: string }[]} records
|
|
1702
|
+
* @param {boolean} fromFile whether the plan came from the evidence file
|
|
1541
1703
|
* @returns {string}
|
|
1542
1704
|
*/
|
|
1543
|
-
function staleOutText(inputs, records) {
|
|
1544
|
-
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);
|
|
1545
1707
|
return lines.length === 0 ? '' : ['## Test Plan', ...lines].join('\n') + '\n';
|
|
1546
1708
|
}
|
|
1547
1709
|
|
|
@@ -1663,7 +1825,7 @@ function verifyPass(io, args, deps) {
|
|
|
1663
1825
|
}, plan.tps.length);
|
|
1664
1826
|
if (line === null) return refusedPass(io, EXIT_CODES.OUTPUT_GATE_REFUSED, 'the EVIDENCE line failed its gate');
|
|
1665
1827
|
|
|
1666
|
-
if (!writeOutputs(args, pr.body, body.blockText, comment.text, staleOutText(inputs, records))) {
|
|
1828
|
+
if (!writeOutputs(args, pr.body, body.blockText, comment.text, staleOutText(inputs, records, fromFile))) {
|
|
1667
1829
|
return refusedPass(io, EXIT_CODES.WRITE_FAILED, 'an output file could not be written');
|
|
1668
1830
|
}
|
|
1669
1831
|
return { outcome: { code: EXIT_CODES.OK, stdout: line + '\n' }, target };
|
|
@@ -1804,7 +1966,7 @@ function runCheck(io, args) {
|
|
|
1804
1966
|
let result;
|
|
1805
1967
|
if (args.what === 'tp') result = PE.parsePlan(sectionOrWhole(text, 'testPlan'));
|
|
1806
1968
|
else if (args.what === 'exceptions') result = PE.parseExceptions(sectionOrWhole(text, 'exceptions'));
|
|
1807
|
-
else if (args.what === 'wave') result = PE.parseWaveBlock(text);
|
|
1969
|
+
else if (args.what === 'wave') result = PE.parseWaveBlock(text, { linksOnly: args.linksOnly });
|
|
1808
1970
|
else {
|
|
1809
1971
|
const b = PE.parseBlock(text);
|
|
1810
1972
|
// R7 pastes only the creation form: TP lines, none ticked.
|
|
@@ -1855,6 +2017,15 @@ const REASON_RE = /^[a-z0-9][a-z0-9 -]{0,59}$/;
|
|
|
1855
2017
|
/** What a `publish` result prints when it could not run at all (exit 2). */
|
|
1856
2018
|
const EVIDENCE_UNAVAILABLE_LINE = 'TRACEABILITY: DEGRADED (evidence unavailable)';
|
|
1857
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
|
+
|
|
1858
2029
|
/**
|
|
1859
2030
|
* @typedef {{ status: string, reason: string | null }} Part
|
|
1860
2031
|
* One half of the `publish` answer: a closed status, or `DEGRADED` with its reason.
|
|
@@ -2120,13 +2291,88 @@ function publishComment(io, pr, fields, files, target) {
|
|
|
2120
2291
|
return post.ok ? okPart('POSTED') : writeFailedPart(post, 'gh pr comment failed');
|
|
2121
2292
|
}
|
|
2122
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' };
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2123
2369
|
/**
|
|
2124
2370
|
* The whole update-pr-evidence operation, in the order the operation always had:
|
|
2125
|
-
* verify; only on `body:changed` scrub, splice, `gh pr edit`,
|
|
2126
|
-
* `posted:no` scrub, then edit the viewer's own evidence comment in
|
|
2127
|
-
* back, or post a new one when there is none to edit or a newer
|
|
2128
|
-
* another login follows it. The work files live in one private
|
|
2129
|
-
* makes and removes.
|
|
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.
|
|
2130
2376
|
*
|
|
2131
2377
|
* @param {Io} io
|
|
2132
2378
|
* @param {Extract<ParsedArgs, { kind: 'publish' }>} args
|
|
@@ -2134,6 +2380,8 @@ function publishComment(io, pr, fields, files, target) {
|
|
|
2134
2380
|
* @returns {Outcome}
|
|
2135
2381
|
*/
|
|
2136
2382
|
function runPublish(io, args, deps) {
|
|
2383
|
+
const refused = publishRefusal(io, deps);
|
|
2384
|
+
if (refused !== null) return refused;
|
|
2137
2385
|
/** @type {string} */
|
|
2138
2386
|
let dir;
|
|
2139
2387
|
try {
|
|
@@ -2466,16 +2714,18 @@ function bindCiHead(ci, viewHead, evidenceHead) {
|
|
|
2466
2714
|
|
|
2467
2715
|
/**
|
|
2468
2716
|
* The test-plan evidence at the head, by `verify --approval`: known only on exit 0
|
|
2469
|
-
* 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.
|
|
2470
2719
|
*
|
|
2471
2720
|
* @param {Io} io
|
|
2472
2721
|
* @param {number} pr
|
|
2722
|
+
* @param {string | null} evidence
|
|
2473
2723
|
* @param {MainDeps} deps
|
|
2474
2724
|
* @returns {any | null} the parsed EVIDENCE fields, or null when the evidence is unknown
|
|
2475
2725
|
*/
|
|
2476
|
-
function readEvidence(io, pr, deps) {
|
|
2726
|
+
function readEvidence(io, pr, evidence, deps) {
|
|
2477
2727
|
const verified = runVerify(io, {
|
|
2478
|
-
kind: 'verify', pr, publication: '', evidence
|
|
2728
|
+
kind: 'verify', pr, publication: '', evidence, state: null,
|
|
2479
2729
|
blockOut: null, commentOut: null, staleOut: null, approval: true,
|
|
2480
2730
|
}, deps);
|
|
2481
2731
|
if (verified.code !== EXIT_CODES.OK) return null;
|
|
@@ -2528,23 +2778,27 @@ function readinessDegraded(pr, reason, known) {
|
|
|
2528
2778
|
* count it cannot establish, is DEGRADED; an unreadable CI status or unknown evidence
|
|
2529
2779
|
* is an input to the ladder, which never lets either reach READY.
|
|
2530
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
|
+
*
|
|
2531
2784
|
* @param {Io} io
|
|
2532
2785
|
* @param {Extract<ParsedArgs, { kind: 'readiness' }>} args
|
|
2533
2786
|
* @param {MainDeps} deps
|
|
2534
|
-
* @returns {
|
|
2787
|
+
* @returns {ReadinessPass}
|
|
2535
2788
|
*/
|
|
2536
|
-
function
|
|
2789
|
+
function readinessPass(io, args, deps) {
|
|
2537
2790
|
const unreadApproval = approvalText(args.requireNonAuthor, null);
|
|
2538
2791
|
const view = readPrView(io, args.pr);
|
|
2539
|
-
if (!view.ok) return readinessDegraded(args.pr, view.reason, { approval: unreadApproval });
|
|
2792
|
+
if (!view.ok) return { outcome: readinessDegraded(args.pr, view.reason, { approval: unreadApproval }), head: null };
|
|
2793
|
+
const head = view.value.head;
|
|
2540
2794
|
const reviewDecision = view.value.decision;
|
|
2541
2795
|
const decisionText = decisionLabel(reviewDecision);
|
|
2542
2796
|
const threads = readThreads(io, args.pr);
|
|
2543
2797
|
if (!threads.ok) {
|
|
2544
|
-
return readinessDegraded(args.pr, threads.reason, { decision: decisionText, approval: unreadApproval });
|
|
2798
|
+
return { outcome: readinessDegraded(args.pr, threads.reason, { decision: decisionText, approval: unreadApproval }), head };
|
|
2545
2799
|
}
|
|
2546
2800
|
const ciRead = readCi(io, args.pr, view.value.mergeable);
|
|
2547
|
-
const fields = readEvidence(io, args.pr, deps);
|
|
2801
|
+
const fields = readEvidence(io, args.pr, args.evidence, deps);
|
|
2548
2802
|
const ci = bindCiHead(ciRead, view.value.head, fields === null ? null : fields.head);
|
|
2549
2803
|
/** @type {EvidenceFacts | null} */
|
|
2550
2804
|
const evidence = fields === null ? null : {
|
|
@@ -2577,7 +2831,95 @@ function runReadiness(io, args, deps) {
|
|
|
2577
2831
|
+ ' (VERIFIED-CI ' + evidence.verifiedCi + ', ATTESTED-LOCAL ' + evidence.attestedLocal + ')',
|
|
2578
2832
|
approval: approvalText(args.requireNonAuthor, evidence),
|
|
2579
2833
|
};
|
|
2580
|
-
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));
|
|
2581
2923
|
}
|
|
2582
2924
|
|
|
2583
2925
|
// ---------------------------------------------------------------------------
|
|
@@ -2644,29 +2986,38 @@ function main(argv, deps) {
|
|
|
2644
2986
|
notes: new Set(),
|
|
2645
2987
|
stderr: typeof d.stderr === 'function' ? d.stderr : (text => { process.stderr.write(text); }),
|
|
2646
2988
|
};
|
|
2647
|
-
const
|
|
2648
|
-
if (
|
|
2989
|
+
const parsed = parseArgs(argv);
|
|
2990
|
+
if (parsed.kind === 'usage') {
|
|
2649
2991
|
io.stderr(USAGE + '\n');
|
|
2650
2992
|
return { code: EXIT_CODES.USAGE, stdout: '' };
|
|
2651
2993
|
}
|
|
2994
|
+
let args = parsed;
|
|
2652
2995
|
/** @type {Outcome} */
|
|
2653
2996
|
let outcome;
|
|
2654
2997
|
try {
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
else
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
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
|
+
}
|
|
2662
3010
|
} catch (err) {
|
|
2663
3011
|
// An injected exec that throws, or a broken invariant (rev): no verdict. The two
|
|
2664
3012
|
// scripted operations still answer in their own DEGRADED form, since a caller
|
|
2665
3013
|
// prints whatever they print.
|
|
2666
3014
|
const label = 'internal error (' + errorLabel(err) + ')';
|
|
2667
3015
|
if (args.kind === 'publish') outcome = evidenceUnavailable(io, label);
|
|
2668
|
-
else if (args.kind === 'readiness')
|
|
2669
|
-
|
|
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);
|
|
2670
3021
|
}
|
|
2671
3022
|
if (io.notes.size > 0) io.stderr('verify-evidence: notes: ' + [...io.notes].sort().join(',') + '\n');
|
|
2672
3023
|
return outcome;
|
|
@@ -2705,7 +3056,7 @@ function settleOutcome(outcome) {
|
|
|
2705
3056
|
if (o.stdout.startsWith('## PR Evidence\n')) {
|
|
2706
3057
|
return o.code === EXIT_CODES.OK && parsePublishBlock(o.stdout) !== null ? pass : failed;
|
|
2707
3058
|
}
|
|
2708
|
-
if (o.stdout ===
|
|
3059
|
+
if (PUBLISH_REFUSAL_LINES.some(line => o.stdout === line + '\n')) return o.code === EXIT_CODES.INPUT_UNUSABLE ? pass : failed;
|
|
2709
3060
|
if (o.stdout.startsWith('## Merge Readiness\n')) {
|
|
2710
3061
|
const block = parseReadinessBlock(o.stdout);
|
|
2711
3062
|
if (block === null) return failed;
|
|
@@ -2766,6 +3117,7 @@ module.exports = Object.freeze({
|
|
|
2766
3117
|
parsePublishBlock,
|
|
2767
3118
|
renderReadinessBlock,
|
|
2768
3119
|
parseReadinessBlock,
|
|
3120
|
+
writeReadinessOut,
|
|
2769
3121
|
commentIdOf,
|
|
2770
3122
|
editTarget,
|
|
2771
3123
|
});
|