@mmerterden/multi-agent-pipeline 20.15.1 → 20.17.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 +45 -0
- package/README.md +6 -5
- package/README.tr.md +6 -5
- package/docs/architecture.md +3 -3
- package/docs/ecosystem.md +5 -5
- package/docs/facts.json +14 -12
- package/docs/features.md +9 -1
- package/install/catalog-history.json +1 -1
- package/install/claude.mjs +40 -1
- package/install/codex.mjs +4 -3
- package/manifest.json +70 -51
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/bug-bash/SKILL.md +154 -0
- package/pipeline/commands/multi-agent/review/SKILL.md +46 -1
- package/pipeline/commands/multi-agent/setup/SKILL.md +8 -6
- package/pipeline/commands/multi-agent/sync/SKILL.md +4 -3
- package/pipeline/commands/multi-agent/update/SKILL.md +5 -1
- package/pipeline/commands/sim-test.md +71 -6
- package/pipeline/lib/plugin-skill-roots.mjs +80 -0
- package/pipeline/lib/usage-lifecycle.mjs +83 -0
- package/pipeline/multi-agent-refs/cross-cli-contract.md +20 -4
- package/pipeline/multi-agent-refs/features/assertion-judge.md +76 -0
- package/pipeline/multi-agent-refs/features/exploratory-testing.md +140 -0
- package/pipeline/multi-agent-refs/features/review-decision.md +2 -1
- package/pipeline/multi-agent-refs/features/skill-conformance.md +1 -1
- package/pipeline/multi-agent-refs/features/usage-reporting.md +59 -5
- package/pipeline/multi-agent-refs/features/valven.md +110 -0
- package/pipeline/multi-agent-refs/help/en.md +2 -0
- package/pipeline/multi-agent-refs/help/tr.md +2 -0
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +2 -1
- package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -0
- package/pipeline/multi-agent-refs/phases/phase-2-dev.md +2 -4
- package/pipeline/multi-agent-refs/phases/phase-3-review.md +5 -5
- package/pipeline/multi-agent-refs/phases/phase-4-commit.md +3 -2
- package/pipeline/schemas/agent-state.schema.json +16 -0
- package/pipeline/schemas/bug-bash-verification.schema.json +41 -0
- package/pipeline/schemas/explore-run.schema.json +108 -0
- package/pipeline/schemas/prefs.schema.json +21 -0
- package/pipeline/scripts/_stack-routing.mjs +2 -0
- package/pipeline/scripts/bug-bash.mjs +379 -0
- package/pipeline/scripts/explore-findings.mjs +410 -0
- package/pipeline/scripts/pr-timeline.mjs +135 -0
- package/pipeline/scripts/pre-commit-check.sh +4 -2
- package/pipeline/scripts/review-decision-gate.mjs +1 -1
- package/pipeline/scripts/skill-conformance.mjs +17 -71
- package/pipeline/scripts/smoke-cross-cli-behavior.sh +1 -1
- package/pipeline/scripts/uninstall.mjs +28 -3
- package/pipeline/scripts/usage-identity.mjs +36 -10
- package/pipeline/scripts/usage-register.mjs +95 -8
- package/pipeline/scripts/usage-report.mjs +52 -2
- package/pipeline/scripts/valven-gate.mjs +1069 -0
- package/pipeline/scripts/valven-port.mjs +169 -0
- package/pipeline/scripts/valven-split.mjs +341 -0
- package/pipeline/scripts/verify-citations.mjs +14 -1
- package/pipeline/skills/.skill-manifest.json +18 -6
- package/pipeline/skills/.skills-index.json +126 -71
- package/pipeline/skills/shared/README.md +8 -5
- package/pipeline/skills/shared/core/multi-agent-bug-bash/SKILL.md +150 -0
- package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +2 -0
- package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +5 -1
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +4 -3
- package/pipeline/skills/shared/core/multi-agent-update/SKILL.md +4 -1
- package/pipeline/skills/shared/external/e2e-testing/SKILL.md +124 -0
- package/pipeline/skills/shared/external/e2e-testing/references/document-fixtures.md +65 -0
- package/pipeline/skills/shared/external/valven-rules/SKILL.md +93 -0
- package/pipeline/skills/shared/external/valven-rules/references/rules.yml +186 -0
- package/pipeline/skills/skills-index.md +76 -71
|
@@ -0,0 +1,1069 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file valven-gate.mjs - measure a change set against the valven-rules
|
|
5
|
+
* registry (skills/.../valven-rules/references/rules.yml, block `gate_rules`).
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* valven-gate.mjs --mode dev|commit|review
|
|
9
|
+
* [--repo <dir>] [--base <ref>] [--head <ref> | --worktree] [--branch <name>]
|
|
10
|
+
* [--diff-file <patch>] [--commits-file <path>] [--pr-body-file <path>]
|
|
11
|
+
* [--pr-meta-file <json>]
|
|
12
|
+
* [--rules <rules.yml>] [--prefs <prefs.json>] [--now <ISO date>]
|
|
13
|
+
* [--out <file>]
|
|
14
|
+
*
|
|
15
|
+
* Two input shapes:
|
|
16
|
+
* - a checkout: --repo plus --base (and --head, default HEAD; --worktree measures
|
|
17
|
+
* the working tree instead, untracked files included). Sizes come from
|
|
18
|
+
* `git diff --numstat -M`, commit subjects from `git log`, the rework ratio
|
|
19
|
+
* from `git blame` of the removed lines at the merge-base.
|
|
20
|
+
* - no checkout (a PR fetched over an API): --diff-file, with --commits-file
|
|
21
|
+
* (one subject per line), --branch and --pr-body-file when known. The rework
|
|
22
|
+
* ratio needs history, so it is reported `not-measured`.
|
|
23
|
+
*
|
|
24
|
+
* --pr-meta-file adds the pull request's timing (`gh pr view --json
|
|
25
|
+
* author,closedAt,createdAt,isDraft,mergedAt,reviewRequests,reviews`, or the normalized
|
|
26
|
+
* shape normalizePrMeta() documents). Without it the pickup and review durations
|
|
27
|
+
* are `not-measured`; coding time is measured up to now in commit mode, where the
|
|
28
|
+
* pull request is about to open.
|
|
29
|
+
*
|
|
30
|
+
* A rule that cannot be measured is `not-measured` with the reason, never a pass.
|
|
31
|
+
* Generated output, lockfiles and snapshots are removed with the same pattern
|
|
32
|
+
* list the reviewers use (review-file-filter.mjs), so no budget counts them.
|
|
33
|
+
*
|
|
34
|
+
* Findings are reviewer-shaped: a PR-level finding carries `line: 0` and the
|
|
35
|
+
* changed file with the largest churn, so post-pr-review.sh posts it as a
|
|
36
|
+
* top-level comment and verify-citations.mjs accepts it (criteriaSource
|
|
37
|
+
* `valven-rules` only).
|
|
38
|
+
*
|
|
39
|
+
* Exit codes:
|
|
40
|
+
* 0 - no important or blocking finding (or the gate is disabled by prefs)
|
|
41
|
+
* 1 - at least one important or blocking finding
|
|
42
|
+
* 2 - setup error: bad arguments, rules file not found or unreadable, git failure
|
|
43
|
+
*
|
|
44
|
+
* @module pipeline/scripts/valven-gate
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { execFileSync } from "node:child_process";
|
|
48
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
49
|
+
import { join } from "node:path";
|
|
50
|
+
import { homedir } from "node:os";
|
|
51
|
+
import { loadPatterns, partition } from "./review-file-filter.mjs";
|
|
52
|
+
import { unquotePathIfNeeded } from "./git-path.mjs";
|
|
53
|
+
import { runMain } from "../lib/fatal.mjs";
|
|
54
|
+
import { invokedDirectly } from "../lib/invoked-directly.mjs";
|
|
55
|
+
import { pluginSkillRootCandidates } from "../lib/plugin-skill-roots.mjs";
|
|
56
|
+
import { messages, outputLanguage, prefsPath } from "../lib/user-facing.mjs";
|
|
57
|
+
|
|
58
|
+
export const CRITERIA_SOURCE = "valven-rules";
|
|
59
|
+
export const TAG = "valven";
|
|
60
|
+
const MODES = new Set(["dev", "commit", "review"]);
|
|
61
|
+
const SOURCE_EXT_RE = /\.(swift|kt|kts|java|m|mm|ts|tsx|js|jsx|mjs|py|go|rb|cs)$/;
|
|
62
|
+
const TEST_PATH_RE =
|
|
63
|
+
/(^|\/)(Tests?|tests?|__tests__|androidTest|UITests?|spec)\/|(Tests?|Spec|\.test|\.spec|_test)\.[a-z]+$/;
|
|
64
|
+
const SPLIT_RULES = new Set(["VLV-SIZE-01", "VLV-SIZE-02", "VLV-SCOPE-01"]);
|
|
65
|
+
const DEFAULT_PATTERNS = join(import.meta.dirname, "..", "schemas", "review-file-exclusions.json");
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Candidate locations of rules.yml. The gate reads the registry that was
|
|
69
|
+
* installed with it, so its evaluators and its rules come from one release: on
|
|
70
|
+
* Claude Code `<root>/multi-agent-refs/registries/valven-rules` (the installer
|
|
71
|
+
* lays it down there because Claude Code loads the skill itself from the
|
|
72
|
+
* ai-common-toolkit plugin), on Copilot `<root>/skills`, on Codex
|
|
73
|
+
* `<root>/multi-agent-refs/skills`, and in a checkout the source. Searching the
|
|
74
|
+
* registries dir before `<root>/skills` also keeps a `~/.claude/skills/valven-rules`
|
|
75
|
+
* left by an older install from being read. The plugin copies come last, for a
|
|
76
|
+
* layout that has none of these.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} [dir] directory of this script
|
|
79
|
+
* @param {string} [home]
|
|
80
|
+
*/
|
|
81
|
+
export function rulesCandidates(dir = import.meta.dirname, home = homedir()) {
|
|
82
|
+
const rel = ["valven-rules", "references", "rules.yml"];
|
|
83
|
+
return [
|
|
84
|
+
join(dir, "..", "multi-agent-refs", "registries", ...rel),
|
|
85
|
+
join(dir, "..", "skills", ...rel),
|
|
86
|
+
join(dir, "..", "multi-agent-refs", "skills", ...rel),
|
|
87
|
+
join(dir, "..", "skills", "shared", "external", ...rel),
|
|
88
|
+
...pluginSkillRootCandidates(home, ["ai-common-toolkit"]).map((root) =>
|
|
89
|
+
join(root, "knowledge", ...rel),
|
|
90
|
+
),
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseScalar(raw) {
|
|
95
|
+
const v = raw.trim();
|
|
96
|
+
if (v.startsWith("[") && v.endsWith("]")) {
|
|
97
|
+
return v
|
|
98
|
+
.slice(1, -1)
|
|
99
|
+
.split(",")
|
|
100
|
+
.map((s) => parseScalar(s))
|
|
101
|
+
.filter((s) => s !== "");
|
|
102
|
+
}
|
|
103
|
+
if (/^".*"$/.test(v)) return JSON.parse(v);
|
|
104
|
+
if (/^'.*'$/.test(v)) return v.slice(1, -1);
|
|
105
|
+
if (/^-?\d+(\.\d+)?$/.test(v)) return Number(v);
|
|
106
|
+
return v;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Read the `gate_rules` block. Fixed indentation, same convention the
|
|
111
|
+
* skill-conformance reader uses: a rule starts at two spaces with `- id:`, its
|
|
112
|
+
* fields sit at four spaces.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} text
|
|
115
|
+
* @returns {{version: string|null, rules: object[]}}
|
|
116
|
+
*/
|
|
117
|
+
export function parseGateRules(text) {
|
|
118
|
+
const rules = [];
|
|
119
|
+
let version = null;
|
|
120
|
+
let inBlock = false;
|
|
121
|
+
let cur = null;
|
|
122
|
+
for (const raw of text.split("\n")) {
|
|
123
|
+
const line = raw.replace(/\s+$/, "");
|
|
124
|
+
if (!line || /^\s*#/.test(line)) continue;
|
|
125
|
+
const top = /^([a-z_]+):(.*)$/.exec(line);
|
|
126
|
+
if (top) {
|
|
127
|
+
if (top[1] === "version") version = top[2].trim() || null;
|
|
128
|
+
inBlock = top[1] === "gate_rules";
|
|
129
|
+
if (cur) rules.push(cur);
|
|
130
|
+
cur = null;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (!inBlock) continue;
|
|
134
|
+
const id = /^ {2}- id:\s*(.+)$/.exec(line);
|
|
135
|
+
if (id) {
|
|
136
|
+
if (cur) rules.push(cur);
|
|
137
|
+
cur = { id: id[1].trim() };
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const field = /^ {4}([a-z_]+):\s*(.*)$/.exec(line);
|
|
141
|
+
if (cur && field) cur[field[1]] = parseScalar(field[2]);
|
|
142
|
+
}
|
|
143
|
+
if (cur) rules.push(cur);
|
|
144
|
+
return { version, rules };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Apply `prefs.global.valven.thresholds` on top of the registry defaults. Only
|
|
149
|
+
* keys the rule already has are taken, so a typo cannot add a threshold the
|
|
150
|
+
* evaluator never reads and leave the default silently in force.
|
|
151
|
+
*/
|
|
152
|
+
export function applyOverrides(rules, overrides) {
|
|
153
|
+
if (!overrides || typeof overrides !== "object") return rules;
|
|
154
|
+
return rules.map((r) => {
|
|
155
|
+
const o = overrides[r.id];
|
|
156
|
+
if (!o || typeof o !== "object") return r;
|
|
157
|
+
const next = { ...r };
|
|
158
|
+
for (const [k, v] of Object.entries(o)) {
|
|
159
|
+
if (Object.hasOwn(r, k) && k !== "id") next[k] = v;
|
|
160
|
+
}
|
|
161
|
+
return next;
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Parse `git diff --numstat -z -M` output.
|
|
167
|
+
*
|
|
168
|
+
* @param {string} out
|
|
169
|
+
* @returns {{path: string, oldPath: string|null, added: number, deleted: number, binary: boolean}[]}
|
|
170
|
+
*/
|
|
171
|
+
export function parseNumstatZ(out) {
|
|
172
|
+
const parts = out.split("\0");
|
|
173
|
+
const files = [];
|
|
174
|
+
for (let i = 0; i < parts.length; i++) {
|
|
175
|
+
const rec = parts[i];
|
|
176
|
+
if (!rec) continue;
|
|
177
|
+
const m = /^(-|\d+)\t(-|\d+)\t(.*)$/s.exec(rec.replace(/^\n/, ""));
|
|
178
|
+
if (!m) continue;
|
|
179
|
+
const binary = m[1] === "-";
|
|
180
|
+
const added = binary ? 0 : Number(m[1]);
|
|
181
|
+
const deleted = binary ? 0 : Number(m[2]);
|
|
182
|
+
if (m[3] === "") {
|
|
183
|
+
const oldPath = parts[++i];
|
|
184
|
+
const path = parts[++i];
|
|
185
|
+
files.push({ path, oldPath, added, deleted, binary });
|
|
186
|
+
} else {
|
|
187
|
+
files.push({ path: m[3], oldPath: null, added, deleted, binary });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return files;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Derive the same per-file stats from a unified patch, for the no-checkout path.
|
|
195
|
+
*
|
|
196
|
+
* @param {string} patch
|
|
197
|
+
*/
|
|
198
|
+
export function parsePatch(patch) {
|
|
199
|
+
const files = [];
|
|
200
|
+
let cur = null;
|
|
201
|
+
let inHunk = false;
|
|
202
|
+
for (const line of patch.split("\n")) {
|
|
203
|
+
const head = /^diff --git a\/(.+) b\/(.+)$/.exec(line);
|
|
204
|
+
if (head) {
|
|
205
|
+
if (cur) files.push(cur);
|
|
206
|
+
cur = {
|
|
207
|
+
path: unquotePathIfNeeded(head[2]),
|
|
208
|
+
oldPath: null,
|
|
209
|
+
added: 0,
|
|
210
|
+
deleted: 0,
|
|
211
|
+
binary: false,
|
|
212
|
+
deletedFile: false,
|
|
213
|
+
};
|
|
214
|
+
inHunk = false;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (!cur) continue;
|
|
218
|
+
if (!inHunk) {
|
|
219
|
+
const from = /^rename from (.+)$/.exec(line);
|
|
220
|
+
if (from) cur.oldPath = unquotePathIfNeeded(from[1]);
|
|
221
|
+
const to = /^rename to (.+)$/.exec(line);
|
|
222
|
+
if (to) cur.path = unquotePathIfNeeded(to[1]);
|
|
223
|
+
if (/^deleted file mode/.test(line)) cur.deletedFile = true;
|
|
224
|
+
if (/^Binary files /.test(line)) cur.binary = true;
|
|
225
|
+
if (line.startsWith("@@")) inHunk = true;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (line.startsWith("+") && !line.startsWith("+++ ")) cur.added++;
|
|
229
|
+
else if (line.startsWith("-") && !line.startsWith("--- ")) cur.deleted++;
|
|
230
|
+
else if (line.startsWith("@@")) inHunk = true;
|
|
231
|
+
}
|
|
232
|
+
if (cur) files.push(cur);
|
|
233
|
+
return files;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export const isTestPath = (p) => TEST_PATH_RE.test(p);
|
|
237
|
+
export const isSourcePath = (p) => SOURCE_EXT_RE.test(p) && !isTestPath(p);
|
|
238
|
+
export const isPureRename = (f) => Boolean(f.oldPath) && f.added === 0 && f.deleted === 0;
|
|
239
|
+
|
|
240
|
+
/** Size and shape metrics over the human-authored files only. */
|
|
241
|
+
export function measure(files, patterns) {
|
|
242
|
+
const { reviewed, excluded } = partition(
|
|
243
|
+
files.map((f) => f.path),
|
|
244
|
+
patterns,
|
|
245
|
+
);
|
|
246
|
+
const keep = new Set(reviewed);
|
|
247
|
+
const human = files.filter((f) => keep.has(f.path));
|
|
248
|
+
const renames = human.filter(isPureRename);
|
|
249
|
+
const content = human.filter((f) => !isPureRename(f));
|
|
250
|
+
const changedLines = human.reduce((n, f) => n + f.added + f.deleted, 0);
|
|
251
|
+
return {
|
|
252
|
+
human,
|
|
253
|
+
excluded,
|
|
254
|
+
changedLines,
|
|
255
|
+
fileCount: human.length,
|
|
256
|
+
renameCount: renames.length,
|
|
257
|
+
contentLines: content.reduce((n, f) => n + f.added + f.deleted, 0),
|
|
258
|
+
sourceFiles: human.filter((f) => isSourcePath(f.path) && !f.deletedFile).map((f) => f.path),
|
|
259
|
+
testFiles: human.filter((f) => isTestPath(f.path)).map((f) => f.path),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** The file a PR-level finding is anchored to: the largest surviving change. */
|
|
264
|
+
export function anchorFile(human) {
|
|
265
|
+
const live = human.filter((f) => !f.deletedFile);
|
|
266
|
+
const pool = live.length ? live : human;
|
|
267
|
+
if (!pool.length) return ".";
|
|
268
|
+
return [...pool].sort((a, b) => b.added + b.deleted - (a.added + a.deleted))[0].path;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The Valven score band of a duration: 100 under `full_under_minutes`, 75 under
|
|
273
|
+
* `three_quarter_under_minutes`, 50 under `half_under_minutes`, otherwise 0.
|
|
274
|
+
* Each bound is exclusive, as in Valven's table ("under 30 min", "30 min - 2.5 hours").
|
|
275
|
+
*
|
|
276
|
+
* @param {number} minutes
|
|
277
|
+
* @param {{full_under_minutes:number, three_quarter_under_minutes:number, half_under_minutes:number}} rule
|
|
278
|
+
*/
|
|
279
|
+
export function bandOf(minutes, rule) {
|
|
280
|
+
if (minutes < rule.full_under_minutes) return 100;
|
|
281
|
+
if (minutes < rule.three_quarter_under_minutes) return 75;
|
|
282
|
+
if (minutes < rule.half_under_minutes) return 50;
|
|
283
|
+
return 0;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const isoOrNull = (v) => {
|
|
287
|
+
if (!v) return null;
|
|
288
|
+
const d = new Date(v);
|
|
289
|
+
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
/** The earliest valid date that is not before `floor`, or null. */
|
|
293
|
+
const earliest = (dates, floor) => {
|
|
294
|
+
const valid = dates
|
|
295
|
+
.map(isoOrNull)
|
|
296
|
+
.filter((d) => d && d >= floor)
|
|
297
|
+
.sort();
|
|
298
|
+
return valid[0] ?? null;
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Pull request timing in one shape. Accepts `gh pr view --json
|
|
303
|
+
* author,closedAt,createdAt,isDraft,mergedAt,reviewRequests,reviews` as is, or the
|
|
304
|
+
* normalized form a Bitbucket caller writes: `{createdAt, readyAt, isDraft,
|
|
305
|
+
* reviewerCount, firstReviewAt, approvedAt, mergedAt, closedAt}`. Reviews by the pull
|
|
306
|
+
* request's own author, bots and pending reviews never count, and a review left
|
|
307
|
+
* before the pull request was ready for review (or before it opened) is not the
|
|
308
|
+
* first review: pickup and review time start at ready. A reviewer count the input does not carry
|
|
309
|
+
* stays null, so it reads as unknown rather than as "no reviewer". Returns null
|
|
310
|
+
* without a valid `createdAt`, because every duration here is anchored to it.
|
|
311
|
+
*
|
|
312
|
+
* @param {object|null} raw
|
|
313
|
+
*/
|
|
314
|
+
export function normalizePrMeta(raw) {
|
|
315
|
+
if (!raw || typeof raw !== "object") return null;
|
|
316
|
+
const createdAt = isoOrNull(raw.createdAt);
|
|
317
|
+
if (!createdAt) return null;
|
|
318
|
+
const base = {
|
|
319
|
+
createdAt,
|
|
320
|
+
readyAt: isoOrNull(raw.readyAt),
|
|
321
|
+
isDraft: raw.isDraft === true,
|
|
322
|
+
mergedAt: isoOrNull(raw.mergedAt),
|
|
323
|
+
closedAt: isoOrNull(raw.closedAt),
|
|
324
|
+
};
|
|
325
|
+
const floor = base.readyAt ?? createdAt;
|
|
326
|
+
if (Array.isArray(raw.reviews) || Array.isArray(raw.reviewRequests)) {
|
|
327
|
+
const author = raw.author?.login ?? null;
|
|
328
|
+
const isBot = (who) => who?.is_bot === true || /\[bot\]$/.test(who?.login ?? "");
|
|
329
|
+
const reviews = (raw.reviews ?? []).filter(
|
|
330
|
+
(r) => r?.author?.login !== author && r?.state !== "PENDING" && !isBot(r?.author),
|
|
331
|
+
);
|
|
332
|
+
const people = new Set([
|
|
333
|
+
...(raw.reviewRequests ?? []).map((r) => r?.login ?? r?.name ?? r?.slug).filter(Boolean),
|
|
334
|
+
...reviews.map((r) => r.author?.login).filter(Boolean),
|
|
335
|
+
]);
|
|
336
|
+
return {
|
|
337
|
+
...base,
|
|
338
|
+
reviewerCount: people.size,
|
|
339
|
+
firstReviewAt: earliest(
|
|
340
|
+
reviews.map((r) => r.submittedAt),
|
|
341
|
+
floor,
|
|
342
|
+
),
|
|
343
|
+
approvedAt: earliest(
|
|
344
|
+
reviews.filter((r) => r.state === "APPROVED").map((r) => r.submittedAt),
|
|
345
|
+
floor,
|
|
346
|
+
),
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
return {
|
|
350
|
+
...base,
|
|
351
|
+
reviewerCount: Number.isFinite(raw.reviewerCount) ? raw.reviewerCount : null,
|
|
352
|
+
firstReviewAt: earliest([raw.firstReviewAt], floor),
|
|
353
|
+
approvedAt: earliest([raw.approvedAt], floor),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const minutesBetween = (from, to) =>
|
|
358
|
+
Math.max(0, Math.floor((new Date(to).getTime() - new Date(from).getTime()) / 60000));
|
|
359
|
+
|
|
360
|
+
function duration(minutes, lang) {
|
|
361
|
+
const tr = lang === "tr";
|
|
362
|
+
if (minutes < 60) return tr ? `${minutes} dakika` : `${minutes} min`;
|
|
363
|
+
const hours = Math.round((minutes / 60) * 10) / 10;
|
|
364
|
+
return tr ? `${String(hours).replace(".", ",")} saat` : `${hours} h`;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const TEXT = {
|
|
368
|
+
en: {
|
|
369
|
+
size: (n, max) =>
|
|
370
|
+
`This pull request changes ${n} lines (budget ${max}). Split it into stacked PRs.`,
|
|
371
|
+
files: (n, max) =>
|
|
372
|
+
`This pull request touches ${n} files (budget ${max}). Split it into stacked PRs.`,
|
|
373
|
+
scope: (r, c) =>
|
|
374
|
+
`${r} pure renames/moves ship together with ${c} lines of content changes. Ship the renames as their own PR.`,
|
|
375
|
+
test: (n) => `${n} source file(s) changed and no test file changed.`,
|
|
376
|
+
branch: (b) => `Branch "${b}" carries no issue key.`,
|
|
377
|
+
commitKey: (s) => `Commit subject without an issue key: "${s}".`,
|
|
378
|
+
wip: (s) => `WIP/fixup commit in the branch: "${s}".`,
|
|
379
|
+
rework: (pct, max, days) =>
|
|
380
|
+
`${pct}% of the removed lines were written in the last ${days} days (budget ${max}%). This is rework.`,
|
|
381
|
+
prBody: (n, min) =>
|
|
382
|
+
n === 0 ? "The PR body is empty." : `The PR body is ${n} characters (minimum ${min}).`,
|
|
383
|
+
prTest: "The PR body does not say how the change was tested.",
|
|
384
|
+
hotfix: (n, max) => `Hotfix branch changes ${n} lines (budget ${max}).`,
|
|
385
|
+
age: (d, max) =>
|
|
386
|
+
`The oldest commit in this pull request is ${d} days old (budget ${max}). Start a fresh branch from the current base and carry the change over with valven-port.`,
|
|
387
|
+
behind: (n, d) =>
|
|
388
|
+
`The branch is ${n} commits behind its base and its merge-base is ${d} days old. Carry the change onto a fresh branch with valven-port.`,
|
|
389
|
+
foreign: (s, key) =>
|
|
390
|
+
`Commit "${s}" belongs to another issue (branch key ${key}). Move it to its own PR.`,
|
|
391
|
+
coding: (d, band) =>
|
|
392
|
+
`Coding took ${d} from the first commit to opening the pull request (Valven score ${band}%). Open the pull request as soon as the first slice works, and keep slices small.`,
|
|
393
|
+
pickup: (d, band, waiting) =>
|
|
394
|
+
waiting
|
|
395
|
+
? `The pull request has waited ${d} for its first review (Valven score ${band}%). Remind the reviewers or ask the team for one.`
|
|
396
|
+
: `The first review came ${d} after the pull request was ready (Valven score ${band}%).`,
|
|
397
|
+
review: (d, band, open) =>
|
|
398
|
+
open
|
|
399
|
+
? `The pull request has been in review for ${d} without an approval (Valven score ${band}%). Answer every comment in one round and push the fixes together.`
|
|
400
|
+
: `Review took ${d} from the first review to approval (Valven score ${band}%). Answer every comment in one round and push the fixes together.`,
|
|
401
|
+
noReviewer:
|
|
402
|
+
"The pull request has no reviewer, so nobody is asked to review it. Assign the default reviewers.",
|
|
403
|
+
draft: (d) =>
|
|
404
|
+
`The pull request has been a draft for ${d}. Mark it ready for review once its gates pass, or close it.`,
|
|
405
|
+
},
|
|
406
|
+
tr: {
|
|
407
|
+
size: (n, max) =>
|
|
408
|
+
`Bu PR ${n} satır değiştiriyor (sınır ${max}). PR'ı zincirleme küçük PR'lara böl.`,
|
|
409
|
+
files: (n, max) =>
|
|
410
|
+
`Bu PR ${n} dosyaya dokunuyor (sınır ${max}). PR'ı zincirleme küçük PR'lara böl.`,
|
|
411
|
+
scope: (r, c) =>
|
|
412
|
+
`${r} saf rename/taşıma, ${c} satırlık içerik değişikliğiyle aynı PR'da. Rename'leri ayrı PR olarak gönder.`,
|
|
413
|
+
test: (n) => `${n} kaynak dosya değişmiş ama hiçbir test dosyası değişmemiş.`,
|
|
414
|
+
branch: (b) => `"${b}" branch adında Jira ID'si yok.`,
|
|
415
|
+
commitKey: (s) => `Jira ID'si olmayan commit: "${s}".`,
|
|
416
|
+
wip: (s) => `Branch'te WIP/fixup commit var: "${s}".`,
|
|
417
|
+
rework: (pct, max, days) =>
|
|
418
|
+
`Silinen satırların %${pct}'i son ${days} günde yazılmış (sınır %${max}). Bu rework.`,
|
|
419
|
+
prBody: (n, min) =>
|
|
420
|
+
n === 0 ? "PR açıklaması boş." : `PR açıklaması ${n} karakter (en az ${min}).`,
|
|
421
|
+
prTest: "PR açıklaması değişikliğin nasıl test edildiğini söylemiyor.",
|
|
422
|
+
hotfix: (n, max) => `Hotfix branch'i ${n} satır değiştiriyor (sınır ${max}).`,
|
|
423
|
+
age: (d, max) =>
|
|
424
|
+
`Bu PR'daki en eski commit ${d} günlük (sınır ${max}). Güncel base'den yeni bir branch aç, değişikliği valven-port ile taşı, yeni PR aç ve bunu kapat.`,
|
|
425
|
+
behind: (n, d) =>
|
|
426
|
+
`Branch base'in ${n} commit gerisinde ve merge-base'i ${d} günlük. Değişikliği valven-port ile güncel base'den açılan yeni bir branch'e taşı.`,
|
|
427
|
+
foreign: (s, key) =>
|
|
428
|
+
`"${s}" commit'i başka bir işe ait (branch ID'si ${key}). Onu ayrı bir PR'a taşı.`,
|
|
429
|
+
coding: (d, band) =>
|
|
430
|
+
`İlk commit'ten PR'ın açılmasına kadar ${d} geçmiş (Valven puanı %${band}). İlk dilim çalışır hale gelince PR'ı hemen aç, dilimleri küçük tut.`,
|
|
431
|
+
pickup: (d, band, waiting) =>
|
|
432
|
+
waiting
|
|
433
|
+
? `PR ${d} süredir ilk review'u bekliyor (Valven puanı %${band}). Reviewer'lara hatırlat ya da takımdan birini iste.`
|
|
434
|
+
: `İlk review, PR hazır olduktan ${d} sonra geldi (Valven puanı %${band}).`,
|
|
435
|
+
review: (d, band, open) =>
|
|
436
|
+
open
|
|
437
|
+
? `PR ${d} süredir review'da ve onay almadı (Valven puanı %${band}). Bütün yorumları tek turda yanıtla, düzeltmeleri birlikte push'la.`
|
|
438
|
+
: `İlk review'dan onaya kadar ${d} geçmiş (Valven puanı %${band}). Bütün yorumları tek turda yanıtla, düzeltmeleri birlikte push'la.`,
|
|
439
|
+
noReviewer: "PR'da reviewer yok, kimseden review istenmiyor. Varsayılan reviewer'ları ekle.",
|
|
440
|
+
draft: (d) =>
|
|
441
|
+
`PR ${d} süredir draft. Gate'leri geçtiyse review'a hazır olarak işaretle ya da kapat.`,
|
|
442
|
+
},
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Evaluate every gate rule. Pure: all I/O results arrive in `input`.
|
|
447
|
+
*
|
|
448
|
+
* @param {object} input
|
|
449
|
+
* @param {"dev"|"commit"|"review"} input.mode
|
|
450
|
+
* @param {object[]} input.rules parsed gate_rules, overrides applied
|
|
451
|
+
* @param {ReturnType<typeof measure>} input.metrics
|
|
452
|
+
* @param {string|null} input.branch
|
|
453
|
+
* @param {string[]|null} input.commits subjects, null when unknown
|
|
454
|
+
* @param {string|null} input.prBody null when unknown
|
|
455
|
+
* @param {{ratio:number, recent:number, removed:number}|{reason:string}|null} input.rework
|
|
456
|
+
* @param {{oldestDays:number|null, oldestCommitAt?:Date|null, behind:number|null, baseDays:number|null}|null} [input.history]
|
|
457
|
+
* @param {ReturnType<typeof normalizePrMeta>} [input.prMeta] null when no pull request is known
|
|
458
|
+
* @param {Date} [input.now]
|
|
459
|
+
* @param {string} [input.lang]
|
|
460
|
+
*/
|
|
461
|
+
export function evaluate({
|
|
462
|
+
mode,
|
|
463
|
+
rules,
|
|
464
|
+
metrics,
|
|
465
|
+
branch,
|
|
466
|
+
commits,
|
|
467
|
+
prBody,
|
|
468
|
+
rework,
|
|
469
|
+
history = null,
|
|
470
|
+
prMeta = null,
|
|
471
|
+
now = new Date(),
|
|
472
|
+
lang = "en",
|
|
473
|
+
}) {
|
|
474
|
+
const t = messages(TEXT, lang);
|
|
475
|
+
const anchor = anchorFile(metrics.human);
|
|
476
|
+
const results = [];
|
|
477
|
+
const findings = [];
|
|
478
|
+
const add = (rule, severity, issue, file = anchor) =>
|
|
479
|
+
findings.push({
|
|
480
|
+
severity,
|
|
481
|
+
file,
|
|
482
|
+
line: 0,
|
|
483
|
+
issue,
|
|
484
|
+
fix: rule.fix,
|
|
485
|
+
ruleId: rule.id,
|
|
486
|
+
criteriaSource: CRITERIA_SOURCE,
|
|
487
|
+
tag: TAG,
|
|
488
|
+
});
|
|
489
|
+
const record = (rule, status, detail, score) =>
|
|
490
|
+
results.push(
|
|
491
|
+
score === undefined
|
|
492
|
+
? { ruleId: rule.id, status, detail }
|
|
493
|
+
: { ruleId: rule.id, status, detail, score },
|
|
494
|
+
);
|
|
495
|
+
const scoreDuration = (rule, minutes, issue, detail) => {
|
|
496
|
+
const band = bandOf(minutes, rule);
|
|
497
|
+
if (band <= 50) {
|
|
498
|
+
add(rule, rule.severity, issue(duration(minutes, lang), band));
|
|
499
|
+
record(rule, "violated", detail, band);
|
|
500
|
+
} else record(rule, "pass", detail, band);
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
for (const rule of rules) {
|
|
504
|
+
const modes = Array.isArray(rule.modes) ? rule.modes : [];
|
|
505
|
+
if (!modes.includes(mode)) {
|
|
506
|
+
record(rule, "not-applicable", `not run in ${mode} mode`);
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
switch (rule.id) {
|
|
510
|
+
case "VLV-SIZE-01": {
|
|
511
|
+
const n = metrics.changedLines;
|
|
512
|
+
if (n > rule.blocking_over) {
|
|
513
|
+
add(rule, "blocking", t.size(n, rule.important_over));
|
|
514
|
+
record(rule, "violated", `${n} > ${rule.blocking_over}`);
|
|
515
|
+
} else if (n > rule.important_over) {
|
|
516
|
+
add(rule, rule.severity, t.size(n, rule.important_over));
|
|
517
|
+
record(rule, "violated", `${n} > ${rule.important_over}`);
|
|
518
|
+
} else record(rule, "pass", `${n} <= ${rule.important_over}`);
|
|
519
|
+
break;
|
|
520
|
+
}
|
|
521
|
+
case "VLV-SIZE-02": {
|
|
522
|
+
const n = metrics.fileCount;
|
|
523
|
+
if (n > rule.important_over) {
|
|
524
|
+
add(rule, rule.severity, t.files(n, rule.important_over));
|
|
525
|
+
record(rule, "violated", `${n} > ${rule.important_over}`);
|
|
526
|
+
} else record(rule, "pass", `${n} <= ${rule.important_over}`);
|
|
527
|
+
break;
|
|
528
|
+
}
|
|
529
|
+
case "VLV-SCOPE-01": {
|
|
530
|
+
const { renameCount: r, contentLines: c } = metrics;
|
|
531
|
+
if (r >= rule.renames_at_least && c > rule.content_lines_over) {
|
|
532
|
+
add(rule, rule.severity, t.scope(r, c));
|
|
533
|
+
record(rule, "violated", `${r} renames + ${c} content lines`);
|
|
534
|
+
} else record(rule, "pass", `${r} renames, ${c} content lines`);
|
|
535
|
+
break;
|
|
536
|
+
}
|
|
537
|
+
case "VLV-TEST-01": {
|
|
538
|
+
const src = metrics.sourceFiles;
|
|
539
|
+
if (src.length && !metrics.testFiles.length) {
|
|
540
|
+
add(rule, rule.severity, t.test(src.length), src[0]);
|
|
541
|
+
record(rule, "violated", `${src.length} source, 0 test`);
|
|
542
|
+
} else if (!src.length) record(rule, "not-applicable", "no source file changed");
|
|
543
|
+
else record(rule, "pass", `${metrics.testFiles.length} test file(s) changed`);
|
|
544
|
+
break;
|
|
545
|
+
}
|
|
546
|
+
case "VLV-TRACE-01": {
|
|
547
|
+
if (!branch || branch === "HEAD") {
|
|
548
|
+
record(rule, "not-measured", "branch name unknown");
|
|
549
|
+
break;
|
|
550
|
+
}
|
|
551
|
+
if (new RegExp(rule.key_pattern).test(branch)) record(rule, "pass", branch);
|
|
552
|
+
else {
|
|
553
|
+
add(rule, rule.severity, t.branch(branch));
|
|
554
|
+
record(rule, "violated", branch);
|
|
555
|
+
}
|
|
556
|
+
break;
|
|
557
|
+
}
|
|
558
|
+
case "VLV-TRACE-02": {
|
|
559
|
+
if (!commits) {
|
|
560
|
+
record(rule, "not-measured", "commit subjects unknown");
|
|
561
|
+
break;
|
|
562
|
+
}
|
|
563
|
+
const re = new RegExp(rule.key_pattern);
|
|
564
|
+
const bad = commits.filter((s) => !re.test(s));
|
|
565
|
+
for (const s of bad) add(rule, rule.severity, t.commitKey(s));
|
|
566
|
+
record(
|
|
567
|
+
rule,
|
|
568
|
+
bad.length ? "violated" : "pass",
|
|
569
|
+
`${bad.length}/${commits.length} without key`,
|
|
570
|
+
);
|
|
571
|
+
break;
|
|
572
|
+
}
|
|
573
|
+
case "VLV-COMMIT-01": {
|
|
574
|
+
if (!commits) {
|
|
575
|
+
record(rule, "not-measured", "commit subjects unknown");
|
|
576
|
+
break;
|
|
577
|
+
}
|
|
578
|
+
const re = new RegExp(rule.subject_pattern, "i");
|
|
579
|
+
const bad = commits.filter((s) => re.test(s.trim()));
|
|
580
|
+
for (const s of bad) add(rule, rule.severity, t.wip(s));
|
|
581
|
+
record(rule, bad.length ? "violated" : "pass", `${bad.length} WIP/fixup commit(s)`);
|
|
582
|
+
break;
|
|
583
|
+
}
|
|
584
|
+
case "VLV-REWORK-01": {
|
|
585
|
+
if (!rework || "reason" in rework) {
|
|
586
|
+
record(rule, "not-measured", rework?.reason ?? "no history");
|
|
587
|
+
break;
|
|
588
|
+
}
|
|
589
|
+
const score = Math.round((1 - rework.ratio) * 100);
|
|
590
|
+
if (rework.removed < rule.min_removed_lines) {
|
|
591
|
+
record(
|
|
592
|
+
rule,
|
|
593
|
+
"pass",
|
|
594
|
+
`${rework.removed} removed lines, below ${rule.min_removed_lines}`,
|
|
595
|
+
score,
|
|
596
|
+
);
|
|
597
|
+
} else if (rework.ratio > rule.ratio_over) {
|
|
598
|
+
const pct = Math.round(rework.ratio * 100);
|
|
599
|
+
add(
|
|
600
|
+
rule,
|
|
601
|
+
rule.severity,
|
|
602
|
+
t.rework(pct, Math.round(rule.ratio_over * 100), rule.window_days),
|
|
603
|
+
);
|
|
604
|
+
record(rule, "violated", `${rework.recent}/${rework.removed}`, score);
|
|
605
|
+
} else record(rule, "pass", `${rework.recent}/${rework.removed}`, score);
|
|
606
|
+
break;
|
|
607
|
+
}
|
|
608
|
+
case "VLV-PRDESC-01": {
|
|
609
|
+
if (prBody === null || prBody === undefined) {
|
|
610
|
+
record(rule, "not-measured", "no PR body (branch mode)");
|
|
611
|
+
break;
|
|
612
|
+
}
|
|
613
|
+
const body = prBody.trim();
|
|
614
|
+
const short = body.length < rule.min_chars;
|
|
615
|
+
const untested = !new RegExp(rule.test_pattern, "i").test(body);
|
|
616
|
+
if (short) add(rule, rule.severity, t.prBody(body.length, rule.min_chars));
|
|
617
|
+
if (untested) add(rule, rule.severity, t.prTest);
|
|
618
|
+
record(rule, short || untested ? "violated" : "pass", `${body.length} chars`);
|
|
619
|
+
break;
|
|
620
|
+
}
|
|
621
|
+
case "VLV-AGE-01": {
|
|
622
|
+
const d = history?.oldestDays;
|
|
623
|
+
if (d === null || d === undefined) {
|
|
624
|
+
record(rule, "not-measured", "commit dates unknown");
|
|
625
|
+
} else if (d > rule.days_over) {
|
|
626
|
+
add(rule, rule.severity, t.age(d, rule.days_over));
|
|
627
|
+
record(rule, "violated", `${d} days`);
|
|
628
|
+
} else record(rule, "pass", `${d} days`);
|
|
629
|
+
break;
|
|
630
|
+
}
|
|
631
|
+
case "VLV-BEHIND-01": {
|
|
632
|
+
const n = history?.behind;
|
|
633
|
+
const d = history?.baseDays;
|
|
634
|
+
if (n === null || n === undefined || d === null || d === undefined) {
|
|
635
|
+
record(rule, "not-measured", "base history unknown (no checkout)");
|
|
636
|
+
} else if (n > rule.behind_over || d > rule.base_days_over) {
|
|
637
|
+
add(rule, rule.severity, t.behind(n, d));
|
|
638
|
+
record(rule, "violated", `${n} behind, merge-base ${d} days`);
|
|
639
|
+
} else record(rule, "pass", `${n} behind, merge-base ${d} days`);
|
|
640
|
+
break;
|
|
641
|
+
}
|
|
642
|
+
case "VLV-TRACE-03": {
|
|
643
|
+
const re = new RegExp(rule.key_pattern, "g");
|
|
644
|
+
const own = branch ? (branch.match(re) ?? [])[0] : undefined;
|
|
645
|
+
if (!commits || !own) {
|
|
646
|
+
record(
|
|
647
|
+
rule,
|
|
648
|
+
"not-measured",
|
|
649
|
+
!commits ? "commit subjects unknown" : "branch carries no key",
|
|
650
|
+
);
|
|
651
|
+
break;
|
|
652
|
+
}
|
|
653
|
+
const foreign = commits.filter((c) => {
|
|
654
|
+
const keys = c.match(re) ?? [];
|
|
655
|
+
return keys.length > 0 && !keys.includes(own);
|
|
656
|
+
});
|
|
657
|
+
for (const c of foreign) add(rule, rule.severity, t.foreign(c, own));
|
|
658
|
+
record(rule, foreign.length ? "violated" : "pass", `${foreign.length} foreign commit(s)`);
|
|
659
|
+
break;
|
|
660
|
+
}
|
|
661
|
+
case "VLV-HOTFIX-01": {
|
|
662
|
+
if (!branch || !new RegExp(rule.branch_pattern).test(branch)) {
|
|
663
|
+
record(rule, "not-applicable", "not a hotfix branch");
|
|
664
|
+
break;
|
|
665
|
+
}
|
|
666
|
+
const n = metrics.changedLines;
|
|
667
|
+
if (n > rule.important_over) {
|
|
668
|
+
add(rule, rule.severity, t.hotfix(n, rule.important_over));
|
|
669
|
+
record(rule, "violated", `${n} > ${rule.important_over}`);
|
|
670
|
+
} else record(rule, "pass", `${n} <= ${rule.important_over}`);
|
|
671
|
+
break;
|
|
672
|
+
}
|
|
673
|
+
case "VLV-CYCLE-01": {
|
|
674
|
+
const start = history?.oldestCommitAt ?? null;
|
|
675
|
+
const end = prMeta ? prMeta.createdAt : mode === "commit" ? now : null;
|
|
676
|
+
if (!start) {
|
|
677
|
+
record(rule, "not-measured", "commit dates unknown");
|
|
678
|
+
break;
|
|
679
|
+
}
|
|
680
|
+
if (!end) {
|
|
681
|
+
record(rule, "not-measured", "no pull request opening time (branch input)");
|
|
682
|
+
break;
|
|
683
|
+
}
|
|
684
|
+
const m = minutesBetween(start, end);
|
|
685
|
+
scoreDuration(rule, m, t.coding, `${m} min to open`);
|
|
686
|
+
break;
|
|
687
|
+
}
|
|
688
|
+
case "VLV-CYCLE-02": {
|
|
689
|
+
if (!prMeta) {
|
|
690
|
+
record(rule, "not-measured", "no pull request metadata");
|
|
691
|
+
break;
|
|
692
|
+
}
|
|
693
|
+
const ready = prMeta.readyAt ?? (prMeta.isDraft ? null : prMeta.createdAt);
|
|
694
|
+
if (!ready) {
|
|
695
|
+
record(rule, "not-measured", "still a draft; the pickup clock has not started");
|
|
696
|
+
break;
|
|
697
|
+
}
|
|
698
|
+
if (!prMeta.firstReviewAt && (prMeta.mergedAt || prMeta.closedAt)) {
|
|
699
|
+
record(
|
|
700
|
+
rule,
|
|
701
|
+
"not-measured",
|
|
702
|
+
prMeta.mergedAt ? "merged without a review" : "closed without a review",
|
|
703
|
+
);
|
|
704
|
+
break;
|
|
705
|
+
}
|
|
706
|
+
const waiting = !prMeta.firstReviewAt;
|
|
707
|
+
const m = minutesBetween(ready, prMeta.firstReviewAt ?? now);
|
|
708
|
+
scoreDuration(
|
|
709
|
+
rule,
|
|
710
|
+
m,
|
|
711
|
+
(d, band) => t.pickup(d, band, waiting),
|
|
712
|
+
waiting ? `${m} min, still waiting` : `${m} min to first review`,
|
|
713
|
+
);
|
|
714
|
+
break;
|
|
715
|
+
}
|
|
716
|
+
case "VLV-CYCLE-03": {
|
|
717
|
+
if (!prMeta?.firstReviewAt) {
|
|
718
|
+
record(rule, "not-measured", prMeta ? "no review yet" : "no pull request metadata");
|
|
719
|
+
break;
|
|
720
|
+
}
|
|
721
|
+
const done = prMeta.approvedAt ?? prMeta.mergedAt ?? prMeta.closedAt;
|
|
722
|
+
const m = minutesBetween(prMeta.firstReviewAt, done ?? now);
|
|
723
|
+
scoreDuration(
|
|
724
|
+
rule,
|
|
725
|
+
m,
|
|
726
|
+
(d, band) => t.review(d, band, !done),
|
|
727
|
+
done ? `${m} min to approval` : `${m} min, still in review`,
|
|
728
|
+
);
|
|
729
|
+
break;
|
|
730
|
+
}
|
|
731
|
+
case "VLV-PICKUP-01": {
|
|
732
|
+
if (!prMeta) {
|
|
733
|
+
record(rule, "not-measured", "no pull request metadata");
|
|
734
|
+
break;
|
|
735
|
+
}
|
|
736
|
+
if (prMeta.mergedAt || prMeta.closedAt) {
|
|
737
|
+
record(rule, "not-applicable", prMeta.mergedAt ? "already merged" : "closed");
|
|
738
|
+
break;
|
|
739
|
+
}
|
|
740
|
+
const problems = [];
|
|
741
|
+
const reviewerUnknown =
|
|
742
|
+
prMeta.reviewerCount === null && !prMeta.firstReviewAt && !prMeta.isDraft;
|
|
743
|
+
if (prMeta.reviewerCount === 0 && !prMeta.firstReviewAt && !prMeta.isDraft) {
|
|
744
|
+
add(rule, rule.severity, t.noReviewer);
|
|
745
|
+
problems.push("no reviewer");
|
|
746
|
+
}
|
|
747
|
+
const draftFor = minutesBetween(prMeta.createdAt, now);
|
|
748
|
+
if (prMeta.isDraft && draftFor > rule.draft_minutes_over) {
|
|
749
|
+
add(rule, rule.draft_severity ?? rule.severity, t.draft(duration(draftFor, lang)));
|
|
750
|
+
problems.push(`draft for ${draftFor} min`);
|
|
751
|
+
}
|
|
752
|
+
if (problems.length) record(rule, "violated", problems.join(", "));
|
|
753
|
+
else if (reviewerUnknown) record(rule, "not-measured", "reviewer count unknown");
|
|
754
|
+
else
|
|
755
|
+
record(
|
|
756
|
+
rule,
|
|
757
|
+
"pass",
|
|
758
|
+
prMeta.reviewerCount === null
|
|
759
|
+
? "already reviewed"
|
|
760
|
+
: `${prMeta.reviewerCount} reviewer(s)`,
|
|
761
|
+
);
|
|
762
|
+
break;
|
|
763
|
+
}
|
|
764
|
+
default:
|
|
765
|
+
record(rule, "not-measured", "rule has no evaluator in this version of the gate");
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const splitRequired = results.some((r) => SPLIT_RULES.has(r.ruleId) && r.status === "violated");
|
|
770
|
+
const failing = findings.some((f) => f.severity === "blocking" || f.severity === "important");
|
|
771
|
+
const scores = Object.fromEntries(
|
|
772
|
+
results.filter((r) => r.score !== undefined).map((r) => [r.ruleId, r.score]),
|
|
773
|
+
);
|
|
774
|
+
return { results, findings, splitRequired, failing, scores };
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// ---------------------------------------------------------------------------
|
|
778
|
+
// I/O
|
|
779
|
+
// ---------------------------------------------------------------------------
|
|
780
|
+
|
|
781
|
+
function git(repo, args) {
|
|
782
|
+
return execFileSync("git", ["-C", repo, ...args], {
|
|
783
|
+
encoding: "utf-8",
|
|
784
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
785
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* One commit line: `<ISO date>\t<subject>` or a bare subject. The dated form is
|
|
791
|
+
* what `git log --format=%aI%x09%s` prints and what a PR caller writes from the
|
|
792
|
+
* provider API; author dates are used so a rebase does not make old work look new.
|
|
793
|
+
*/
|
|
794
|
+
export function parseCommitLine(line) {
|
|
795
|
+
const m = /^(\d{4}-\d{2}-\d{2}T[^\t]+)\t(.*)$/.exec(line);
|
|
796
|
+
if (!m) return { date: null, subject: line };
|
|
797
|
+
const date = new Date(m[1]);
|
|
798
|
+
return { date: Number.isNaN(date.getTime()) ? null : date, subject: m[2] };
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/** Whole days from `from` to `to`, never negative. */
|
|
802
|
+
export function daysBetween(from, to) {
|
|
803
|
+
return Math.max(0, Math.floor((to.getTime() - from.getTime()) / 86400000));
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/** Removed-line hunks of one file, as [start, count] pairs on the old side. */
|
|
807
|
+
export function removedRanges(unifiedZero) {
|
|
808
|
+
const out = [];
|
|
809
|
+
for (const line of unifiedZero.split("\n")) {
|
|
810
|
+
const m = /^@@ -(\d+)(?:,(\d+))? \+/.exec(line);
|
|
811
|
+
if (!m) continue;
|
|
812
|
+
const count = m[2] === undefined ? 1 : Number(m[2]);
|
|
813
|
+
if (count > 0) out.push([Number(m[1]), count]);
|
|
814
|
+
}
|
|
815
|
+
return out;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/** Committer times (epoch seconds) from `git blame --line-porcelain`. */
|
|
819
|
+
export function blameTimes(porcelain) {
|
|
820
|
+
const times = [];
|
|
821
|
+
for (const line of porcelain.split("\n")) {
|
|
822
|
+
const m = /^committer-time (\d+)$/.exec(line);
|
|
823
|
+
if (m) times.push(Number(m[1]));
|
|
824
|
+
}
|
|
825
|
+
return times;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Share of the removed lines that were committed inside the window, blamed at
|
|
830
|
+
* the merge-base so the branch's own commits never count against it.
|
|
831
|
+
*/
|
|
832
|
+
function measureRework(repo, mergeBase, range, human, windowDays, now) {
|
|
833
|
+
const cutoff = Math.floor(now.getTime() / 1000) - windowDays * 86400;
|
|
834
|
+
let removed = 0;
|
|
835
|
+
let recent = 0;
|
|
836
|
+
for (const f of human) {
|
|
837
|
+
if (f.deleted === 0 || f.binary) continue;
|
|
838
|
+
const old = f.oldPath ?? f.path;
|
|
839
|
+
if (f.untracked) continue;
|
|
840
|
+
const diff = git(repo, ["diff", "-U0", "-M", ...range, "--", old, f.path]);
|
|
841
|
+
for (const [start, count] of removedRanges(diff)) {
|
|
842
|
+
const porcelain = git(repo, [
|
|
843
|
+
"blame",
|
|
844
|
+
"--line-porcelain",
|
|
845
|
+
"-L",
|
|
846
|
+
`${start},+${count}`,
|
|
847
|
+
mergeBase,
|
|
848
|
+
"--",
|
|
849
|
+
old,
|
|
850
|
+
]);
|
|
851
|
+
const times = blameTimes(porcelain);
|
|
852
|
+
removed += times.length;
|
|
853
|
+
recent += times.filter((s) => s >= cutoff).length;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
return { removed, recent, ratio: removed ? recent / removed : 0 };
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/** Untracked, not ignored files, counted as fully added. A file holding a NUL byte is binary. */
|
|
860
|
+
function untrackedFiles(repo) {
|
|
861
|
+
return git(repo, ["ls-files", "--others", "--exclude-standard", "-z"])
|
|
862
|
+
.split("\0")
|
|
863
|
+
.filter(Boolean)
|
|
864
|
+
.map((path) => {
|
|
865
|
+
let text;
|
|
866
|
+
try {
|
|
867
|
+
text = readFileSync(join(repo, path), "utf-8");
|
|
868
|
+
} catch {
|
|
869
|
+
text = "";
|
|
870
|
+
}
|
|
871
|
+
const binary = text.includes("\0");
|
|
872
|
+
const added = binary || !text ? 0 : text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
|
|
873
|
+
return {
|
|
874
|
+
path,
|
|
875
|
+
oldPath: null,
|
|
876
|
+
added,
|
|
877
|
+
deleted: 0,
|
|
878
|
+
binary,
|
|
879
|
+
deletedFile: false,
|
|
880
|
+
untracked: true,
|
|
881
|
+
};
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function readPrefs(path) {
|
|
886
|
+
try {
|
|
887
|
+
return JSON.parse(readFileSync(path, "utf-8"))?.global?.valven ?? {};
|
|
888
|
+
} catch {
|
|
889
|
+
return {};
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function parseArgs(argv) {
|
|
894
|
+
const args = {};
|
|
895
|
+
for (let i = 0; i < argv.length; i++) {
|
|
896
|
+
const a = argv[i];
|
|
897
|
+
if (a === "--worktree") {
|
|
898
|
+
args.worktree = true;
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
if (!a.startsWith("--")) throw new Error(`unexpected argument ${a}`);
|
|
902
|
+
const key = a.slice(2);
|
|
903
|
+
const val = argv[i + 1];
|
|
904
|
+
if (val === undefined || val.startsWith("--")) throw new Error(`${a} needs a value`);
|
|
905
|
+
args[key] = val;
|
|
906
|
+
i++;
|
|
907
|
+
}
|
|
908
|
+
return args;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function fail(msg) {
|
|
912
|
+
process.stderr.write(`valven-gate: ${msg}\n`);
|
|
913
|
+
process.exitCode = 2;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
async function main() {
|
|
917
|
+
let args;
|
|
918
|
+
try {
|
|
919
|
+
args = parseArgs(process.argv.slice(2));
|
|
920
|
+
} catch (err) {
|
|
921
|
+
return fail(err.message);
|
|
922
|
+
}
|
|
923
|
+
if (!MODES.has(args.mode)) return fail("--mode must be dev, commit or review");
|
|
924
|
+
|
|
925
|
+
const prefsFile = args.prefs ?? prefsPath();
|
|
926
|
+
const prefs = readPrefs(prefsFile);
|
|
927
|
+
if (prefs.enabled === false) {
|
|
928
|
+
process.stderr.write("valven: disabled by prefs\n");
|
|
929
|
+
const report = { mode: args.mode, enabled: false, results: [], findings: [] };
|
|
930
|
+
if (args.out) writeFileSync(args.out, `${JSON.stringify(report, null, 2)}\n`);
|
|
931
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
const rulesPath = args.rules ?? rulesCandidates().find((p) => existsSync(p));
|
|
936
|
+
if (!rulesPath || !existsSync(rulesPath)) {
|
|
937
|
+
return fail(`rules.yml not found (searched ${rulesCandidates().join(", ")})`);
|
|
938
|
+
}
|
|
939
|
+
const { version, rules: parsed } = parseGateRules(readFileSync(rulesPath, "utf-8"));
|
|
940
|
+
if (!parsed.length) return fail(`${rulesPath} has no gate_rules`);
|
|
941
|
+
const rules = applyOverrides(parsed, prefs.thresholds);
|
|
942
|
+
|
|
943
|
+
const { patterns, error: patternError } = loadPatterns(args.patterns ?? DEFAULT_PATTERNS);
|
|
944
|
+
if (patternError) process.stderr.write(`valven-gate: ${patternError}; counting every file\n`);
|
|
945
|
+
|
|
946
|
+
const repo = args.repo ?? process.cwd();
|
|
947
|
+
const head = args.head ?? "HEAD";
|
|
948
|
+
let files;
|
|
949
|
+
let commits = null;
|
|
950
|
+
let branch = args.branch ?? null;
|
|
951
|
+
let rework = { reason: "no checkout (--diff-file input)" };
|
|
952
|
+
let commitDates = [];
|
|
953
|
+
let history = { behind: null, baseDays: null };
|
|
954
|
+
const now = args.now ? new Date(args.now) : new Date();
|
|
955
|
+
|
|
956
|
+
try {
|
|
957
|
+
if (args["diff-file"]) {
|
|
958
|
+
files = parsePatch(readFileSync(args["diff-file"], "utf-8"));
|
|
959
|
+
} else {
|
|
960
|
+
if (!args.base) return fail("--base is required without --diff-file");
|
|
961
|
+
const mergeBase = git(repo, ["merge-base", args.base, head]).trim();
|
|
962
|
+
const range = args.worktree ? [mergeBase] : [mergeBase, head];
|
|
963
|
+
files = parseNumstatZ(git(repo, ["diff", "--numstat", "-z", "-M", ...range]));
|
|
964
|
+
const deleted = new Set(
|
|
965
|
+
git(repo, ["diff", "--name-only", "-z", "--diff-filter=D", ...range])
|
|
966
|
+
.split("\0")
|
|
967
|
+
.filter(Boolean),
|
|
968
|
+
);
|
|
969
|
+
for (const f of files) f.deletedFile = deleted.has(f.path);
|
|
970
|
+
if (args.worktree) files.push(...untrackedFiles(repo));
|
|
971
|
+
const log = git(repo, ["log", "--no-merges", "--format=%aI%x09%s", `${mergeBase}..${head}`])
|
|
972
|
+
.split("\n")
|
|
973
|
+
.filter(Boolean)
|
|
974
|
+
.map(parseCommitLine);
|
|
975
|
+
commits = log.map((c) => c.subject);
|
|
976
|
+
commitDates = log.map((c) => c.date).filter(Boolean);
|
|
977
|
+
const behind = Number(git(repo, ["rev-list", "--count", `${head}..${args.base}`]).trim());
|
|
978
|
+
const baseDate = git(repo, ["log", "-1", "--format=%cI", mergeBase]).trim();
|
|
979
|
+
history = { behind, baseDays: daysBetween(new Date(baseDate), now) };
|
|
980
|
+
if (!branch) branch = git(repo, ["rev-parse", "--abbrev-ref", head]).trim();
|
|
981
|
+
const reworkRule = rules.find((r) => r.id === "VLV-REWORK-01");
|
|
982
|
+
if (reworkRule) {
|
|
983
|
+
const metricsForRework = measure(files, patterns);
|
|
984
|
+
rework = measureRework(
|
|
985
|
+
repo,
|
|
986
|
+
mergeBase,
|
|
987
|
+
range,
|
|
988
|
+
metricsForRework.human,
|
|
989
|
+
reworkRule.window_days,
|
|
990
|
+
now,
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
} catch (err) {
|
|
995
|
+
return fail(`git failed: ${String(err.stderr || err.message).trim()}`);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
if (args["commits-file"]) {
|
|
999
|
+
const log = readFileSync(args["commits-file"], "utf-8")
|
|
1000
|
+
.split("\n")
|
|
1001
|
+
.map((s) => s.trim())
|
|
1002
|
+
.filter(Boolean)
|
|
1003
|
+
.map(parseCommitLine);
|
|
1004
|
+
commits = log.map((c) => c.subject);
|
|
1005
|
+
commitDates = log.map((c) => c.date).filter(Boolean);
|
|
1006
|
+
}
|
|
1007
|
+
const oldest = commitDates.length ? Math.min(...commitDates.map((d) => d.getTime())) : null;
|
|
1008
|
+
history.oldestDays = oldest === null ? null : daysBetween(new Date(oldest), now);
|
|
1009
|
+
history.oldestCommitAt = oldest === null ? null : new Date(oldest);
|
|
1010
|
+
const prBody = args["pr-body-file"] ? readFileSync(args["pr-body-file"], "utf-8") : null;
|
|
1011
|
+
let prMeta = null;
|
|
1012
|
+
if (args["pr-meta-file"]) {
|
|
1013
|
+
let problem = null;
|
|
1014
|
+
try {
|
|
1015
|
+
prMeta = normalizePrMeta(JSON.parse(readFileSync(args["pr-meta-file"], "utf-8")));
|
|
1016
|
+
if (!prMeta) problem = "no valid createdAt";
|
|
1017
|
+
} catch (err) {
|
|
1018
|
+
problem = err.message;
|
|
1019
|
+
}
|
|
1020
|
+
if (problem) {
|
|
1021
|
+
process.stderr.write(
|
|
1022
|
+
`valven-gate: --pr-meta-file ${args["pr-meta-file"]}: ${problem}; the timeline rules report not-measured\n`,
|
|
1023
|
+
);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
const metrics = measure(files, patterns);
|
|
1028
|
+
const verdict = evaluate({
|
|
1029
|
+
mode: args.mode,
|
|
1030
|
+
rules,
|
|
1031
|
+
metrics,
|
|
1032
|
+
branch,
|
|
1033
|
+
commits,
|
|
1034
|
+
prBody,
|
|
1035
|
+
rework,
|
|
1036
|
+
history,
|
|
1037
|
+
prMeta,
|
|
1038
|
+
now,
|
|
1039
|
+
lang: args.lang ?? outputLanguage(prefsFile),
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
const report = {
|
|
1043
|
+
mode: args.mode,
|
|
1044
|
+
enabled: true,
|
|
1045
|
+
rulesVersion: version,
|
|
1046
|
+
rulesPath,
|
|
1047
|
+
metrics: {
|
|
1048
|
+
changedLines: metrics.changedLines,
|
|
1049
|
+
fileCount: metrics.fileCount,
|
|
1050
|
+
renameCount: metrics.renameCount,
|
|
1051
|
+
contentLines: metrics.contentLines,
|
|
1052
|
+
sourceFiles: metrics.sourceFiles.length,
|
|
1053
|
+
testFiles: metrics.testFiles.length,
|
|
1054
|
+
excludedFiles: metrics.excluded.length,
|
|
1055
|
+
rework: "reason" in rework ? null : rework,
|
|
1056
|
+
},
|
|
1057
|
+
prMeta,
|
|
1058
|
+
scores: verdict.scores,
|
|
1059
|
+
results: verdict.results,
|
|
1060
|
+
findings: verdict.findings,
|
|
1061
|
+
splitRequired: verdict.splitRequired,
|
|
1062
|
+
};
|
|
1063
|
+
const json = `${JSON.stringify(report, null, 2)}\n`;
|
|
1064
|
+
if (args.out) writeFileSync(args.out, json);
|
|
1065
|
+
process.stdout.write(json);
|
|
1066
|
+
if (verdict.failing) process.exitCode = 1;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
if (invokedDirectly(import.meta.url)) runMain("valven-gate", main, { code: 2 });
|