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