@mmerterden/multi-agent-pipeline 20.15.0 → 20.16.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 +22 -0
- package/docs/facts.json +5 -4
- package/install/catalog-history.json +1 -1
- package/install/claude.mjs +5 -1
- package/install/codex.mjs +4 -3
- package/manifest.json +36 -30
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/review/SKILL.md +42 -1
- package/pipeline/multi-agent-refs/features/review-decision.md +2 -1
- package/pipeline/multi-agent-refs/features/valven.md +83 -0
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -0
- 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 +1 -0
- package/pipeline/schemas/agent-state.schema.json +16 -0
- package/pipeline/schemas/prefs.schema.json +21 -0
- package/pipeline/scripts/gen-skills-index.mjs +2 -1
- package/pipeline/scripts/phase-tracker.sh +8 -0
- package/pipeline/scripts/pre-push-check.sh +3 -1
- package/pipeline/scripts/review-decision-gate.mjs +1 -1
- package/pipeline/scripts/uninstall.mjs +5 -1
- package/pipeline/scripts/valven-gate.mjs +788 -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 +7 -3
- package/pipeline/skills/.skills-index.json +104 -71
- package/pipeline/skills/shared/README.md +4 -3
- package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +2 -0
- package/pipeline/skills/shared/core/valven-rules/SKILL.md +62 -0
- package/pipeline/skills/shared/core/valven-rules/references/rules.yml +140 -0
- package/pipeline/skills/skills-index.md +74 -71
|
@@ -0,0 +1,788 @@
|
|
|
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
|
+
* [--rules <rules.yml>] [--prefs <prefs.json>] [--now <ISO date>]
|
|
12
|
+
* [--out <file>]
|
|
13
|
+
*
|
|
14
|
+
* Two input shapes:
|
|
15
|
+
* - a checkout: --repo plus --base (and --head, default HEAD; --worktree measures
|
|
16
|
+
* the working tree instead, untracked files included). Sizes come from
|
|
17
|
+
* `git diff --numstat -M`, commit subjects from `git log`, the rework ratio
|
|
18
|
+
* from `git blame` of the removed lines at the merge-base.
|
|
19
|
+
* - no checkout (a PR fetched over an API): --diff-file, with --commits-file
|
|
20
|
+
* (one subject per line), --branch and --pr-body-file when known. The rework
|
|
21
|
+
* ratio needs history, so it is reported `not-measured`.
|
|
22
|
+
*
|
|
23
|
+
* A rule that cannot be measured is `not-measured` with the reason, never a pass.
|
|
24
|
+
* Generated output, lockfiles and snapshots are removed with the same pattern
|
|
25
|
+
* list the reviewers use (review-file-filter.mjs), so no budget counts them.
|
|
26
|
+
*
|
|
27
|
+
* Findings are reviewer-shaped: a PR-level finding carries `line: 0` and the
|
|
28
|
+
* changed file with the largest churn, so post-pr-review.sh posts it as a
|
|
29
|
+
* top-level comment and verify-citations.mjs accepts it (criteriaSource
|
|
30
|
+
* `valven-rules` only).
|
|
31
|
+
*
|
|
32
|
+
* Exit codes:
|
|
33
|
+
* 0 - no important or blocking finding (or the gate is disabled by prefs)
|
|
34
|
+
* 1 - at least one important or blocking finding
|
|
35
|
+
* 2 - setup error: bad arguments, rules file not found or unreadable, git failure
|
|
36
|
+
*
|
|
37
|
+
* @module pipeline/scripts/valven-gate
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { execFileSync } from "node:child_process";
|
|
41
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
42
|
+
import { join } from "node:path";
|
|
43
|
+
import { loadPatterns, partition } from "./review-file-filter.mjs";
|
|
44
|
+
import { unquotePathIfNeeded } from "./git-path.mjs";
|
|
45
|
+
import { runMain } from "../lib/fatal.mjs";
|
|
46
|
+
import { invokedDirectly } from "../lib/invoked-directly.mjs";
|
|
47
|
+
import { messages, outputLanguage, prefsPath } from "../lib/user-facing.mjs";
|
|
48
|
+
|
|
49
|
+
export const CRITERIA_SOURCE = "valven-rules";
|
|
50
|
+
export const TAG = "valven";
|
|
51
|
+
const MODES = new Set(["dev", "commit", "review"]);
|
|
52
|
+
const SOURCE_EXT_RE = /\.(swift|kt|kts|java|m|mm|ts|tsx|js|jsx|mjs|py|go|rb|cs)$/;
|
|
53
|
+
const TEST_PATH_RE =
|
|
54
|
+
/(^|\/)(Tests?|tests?|__tests__|androidTest|UITests?|spec)\/|(Tests?|Spec|\.test|\.spec|_test)\.[a-z]+$/;
|
|
55
|
+
const SPLIT_RULES = new Set(["VLV-SIZE-01", "VLV-SIZE-02", "VLV-SCOPE-01"]);
|
|
56
|
+
const DEFAULT_PATTERNS = join(import.meta.dirname, "..", "schemas", "review-file-exclusions.json");
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Candidate locations of rules.yml relative to this script, installed layouts
|
|
60
|
+
* first: Claude Code and Copilot (`<root>/skills`), Codex
|
|
61
|
+
* (`<root>/multi-agent-refs/skills`), then the source checkout.
|
|
62
|
+
*/
|
|
63
|
+
export function rulesCandidates(dir = import.meta.dirname) {
|
|
64
|
+
const rel = ["valven-rules", "references", "rules.yml"];
|
|
65
|
+
return [
|
|
66
|
+
join(dir, "..", "skills", ...rel),
|
|
67
|
+
join(dir, "..", "multi-agent-refs", "skills", ...rel),
|
|
68
|
+
join(dir, "..", "skills", "shared", "core", ...rel),
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseScalar(raw) {
|
|
73
|
+
const v = raw.trim();
|
|
74
|
+
if (v.startsWith("[") && v.endsWith("]")) {
|
|
75
|
+
return v
|
|
76
|
+
.slice(1, -1)
|
|
77
|
+
.split(",")
|
|
78
|
+
.map((s) => parseScalar(s))
|
|
79
|
+
.filter((s) => s !== "");
|
|
80
|
+
}
|
|
81
|
+
if (/^".*"$/.test(v)) return JSON.parse(v);
|
|
82
|
+
if (/^'.*'$/.test(v)) return v.slice(1, -1);
|
|
83
|
+
if (/^-?\d+(\.\d+)?$/.test(v)) return Number(v);
|
|
84
|
+
return v;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Read the `gate_rules` block. Fixed indentation, same convention the
|
|
89
|
+
* skill-conformance reader uses: a rule starts at two spaces with `- id:`, its
|
|
90
|
+
* fields sit at four spaces.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} text
|
|
93
|
+
* @returns {{version: string|null, rules: object[]}}
|
|
94
|
+
*/
|
|
95
|
+
export function parseGateRules(text) {
|
|
96
|
+
const rules = [];
|
|
97
|
+
let version = null;
|
|
98
|
+
let inBlock = false;
|
|
99
|
+
let cur = null;
|
|
100
|
+
for (const raw of text.split("\n")) {
|
|
101
|
+
const line = raw.replace(/\s+$/, "");
|
|
102
|
+
if (!line || /^\s*#/.test(line)) continue;
|
|
103
|
+
const top = /^([a-z_]+):(.*)$/.exec(line);
|
|
104
|
+
if (top) {
|
|
105
|
+
if (top[1] === "version") version = top[2].trim() || null;
|
|
106
|
+
inBlock = top[1] === "gate_rules";
|
|
107
|
+
if (cur) rules.push(cur);
|
|
108
|
+
cur = null;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (!inBlock) continue;
|
|
112
|
+
const id = /^ {2}- id:\s*(.+)$/.exec(line);
|
|
113
|
+
if (id) {
|
|
114
|
+
if (cur) rules.push(cur);
|
|
115
|
+
cur = { id: id[1].trim() };
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const field = /^ {4}([a-z_]+):\s*(.*)$/.exec(line);
|
|
119
|
+
if (cur && field) cur[field[1]] = parseScalar(field[2]);
|
|
120
|
+
}
|
|
121
|
+
if (cur) rules.push(cur);
|
|
122
|
+
return { version, rules };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Apply `prefs.global.valven.thresholds` on top of the registry defaults. Only
|
|
127
|
+
* keys the rule already has are taken, so a typo cannot add a threshold the
|
|
128
|
+
* evaluator never reads and leave the default silently in force.
|
|
129
|
+
*/
|
|
130
|
+
export function applyOverrides(rules, overrides) {
|
|
131
|
+
if (!overrides || typeof overrides !== "object") return rules;
|
|
132
|
+
return rules.map((r) => {
|
|
133
|
+
const o = overrides[r.id];
|
|
134
|
+
if (!o || typeof o !== "object") return r;
|
|
135
|
+
const next = { ...r };
|
|
136
|
+
for (const [k, v] of Object.entries(o)) {
|
|
137
|
+
if (Object.hasOwn(r, k) && k !== "id") next[k] = v;
|
|
138
|
+
}
|
|
139
|
+
return next;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Parse `git diff --numstat -z -M` output.
|
|
145
|
+
*
|
|
146
|
+
* @param {string} out
|
|
147
|
+
* @returns {{path: string, oldPath: string|null, added: number, deleted: number, binary: boolean}[]}
|
|
148
|
+
*/
|
|
149
|
+
export function parseNumstatZ(out) {
|
|
150
|
+
const parts = out.split("\0");
|
|
151
|
+
const files = [];
|
|
152
|
+
for (let i = 0; i < parts.length; i++) {
|
|
153
|
+
const rec = parts[i];
|
|
154
|
+
if (!rec) continue;
|
|
155
|
+
const m = /^(-|\d+)\t(-|\d+)\t(.*)$/s.exec(rec.replace(/^\n/, ""));
|
|
156
|
+
if (!m) continue;
|
|
157
|
+
const binary = m[1] === "-";
|
|
158
|
+
const added = binary ? 0 : Number(m[1]);
|
|
159
|
+
const deleted = binary ? 0 : Number(m[2]);
|
|
160
|
+
if (m[3] === "") {
|
|
161
|
+
const oldPath = parts[++i];
|
|
162
|
+
const path = parts[++i];
|
|
163
|
+
files.push({ path, oldPath, added, deleted, binary });
|
|
164
|
+
} else {
|
|
165
|
+
files.push({ path: m[3], oldPath: null, added, deleted, binary });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return files;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Derive the same per-file stats from a unified patch, for the no-checkout path.
|
|
173
|
+
*
|
|
174
|
+
* @param {string} patch
|
|
175
|
+
*/
|
|
176
|
+
export function parsePatch(patch) {
|
|
177
|
+
const files = [];
|
|
178
|
+
let cur = null;
|
|
179
|
+
let inHunk = false;
|
|
180
|
+
for (const line of patch.split("\n")) {
|
|
181
|
+
const head = /^diff --git a\/(.+) b\/(.+)$/.exec(line);
|
|
182
|
+
if (head) {
|
|
183
|
+
if (cur) files.push(cur);
|
|
184
|
+
cur = {
|
|
185
|
+
path: unquotePathIfNeeded(head[2]),
|
|
186
|
+
oldPath: null,
|
|
187
|
+
added: 0,
|
|
188
|
+
deleted: 0,
|
|
189
|
+
binary: false,
|
|
190
|
+
deletedFile: false,
|
|
191
|
+
};
|
|
192
|
+
inHunk = false;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (!cur) continue;
|
|
196
|
+
if (!inHunk) {
|
|
197
|
+
const from = /^rename from (.+)$/.exec(line);
|
|
198
|
+
if (from) cur.oldPath = unquotePathIfNeeded(from[1]);
|
|
199
|
+
const to = /^rename to (.+)$/.exec(line);
|
|
200
|
+
if (to) cur.path = unquotePathIfNeeded(to[1]);
|
|
201
|
+
if (/^deleted file mode/.test(line)) cur.deletedFile = true;
|
|
202
|
+
if (/^Binary files /.test(line)) cur.binary = true;
|
|
203
|
+
if (line.startsWith("@@")) inHunk = true;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (line.startsWith("+") && !line.startsWith("+++ ")) cur.added++;
|
|
207
|
+
else if (line.startsWith("-") && !line.startsWith("--- ")) cur.deleted++;
|
|
208
|
+
else if (line.startsWith("@@")) inHunk = true;
|
|
209
|
+
}
|
|
210
|
+
if (cur) files.push(cur);
|
|
211
|
+
return files;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export const isTestPath = (p) => TEST_PATH_RE.test(p);
|
|
215
|
+
export const isSourcePath = (p) => SOURCE_EXT_RE.test(p) && !isTestPath(p);
|
|
216
|
+
export const isPureRename = (f) => Boolean(f.oldPath) && f.added === 0 && f.deleted === 0;
|
|
217
|
+
|
|
218
|
+
/** Size and shape metrics over the human-authored files only. */
|
|
219
|
+
export function measure(files, patterns) {
|
|
220
|
+
const { reviewed, excluded } = partition(
|
|
221
|
+
files.map((f) => f.path),
|
|
222
|
+
patterns,
|
|
223
|
+
);
|
|
224
|
+
const keep = new Set(reviewed);
|
|
225
|
+
const human = files.filter((f) => keep.has(f.path));
|
|
226
|
+
const renames = human.filter(isPureRename);
|
|
227
|
+
const content = human.filter((f) => !isPureRename(f));
|
|
228
|
+
const changedLines = human.reduce((n, f) => n + f.added + f.deleted, 0);
|
|
229
|
+
return {
|
|
230
|
+
human,
|
|
231
|
+
excluded,
|
|
232
|
+
changedLines,
|
|
233
|
+
fileCount: human.length,
|
|
234
|
+
renameCount: renames.length,
|
|
235
|
+
contentLines: content.reduce((n, f) => n + f.added + f.deleted, 0),
|
|
236
|
+
sourceFiles: human.filter((f) => isSourcePath(f.path) && !f.deletedFile).map((f) => f.path),
|
|
237
|
+
testFiles: human.filter((f) => isTestPath(f.path)).map((f) => f.path),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The file a PR-level finding is anchored to: the largest surviving change. */
|
|
242
|
+
export function anchorFile(human) {
|
|
243
|
+
const live = human.filter((f) => !f.deletedFile);
|
|
244
|
+
const pool = live.length ? live : human;
|
|
245
|
+
if (!pool.length) return ".";
|
|
246
|
+
return [...pool].sort((a, b) => b.added + b.deleted - (a.added + a.deleted))[0].path;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const TEXT = {
|
|
250
|
+
en: {
|
|
251
|
+
size: (n, max) =>
|
|
252
|
+
`This pull request changes ${n} lines (budget ${max}). Split it into stacked PRs.`,
|
|
253
|
+
files: (n, max) =>
|
|
254
|
+
`This pull request touches ${n} files (budget ${max}). Split it into stacked PRs.`,
|
|
255
|
+
scope: (r, c) =>
|
|
256
|
+
`${r} pure renames/moves ship together with ${c} lines of content changes. Ship the renames as their own PR.`,
|
|
257
|
+
test: (n) => `${n} source file(s) changed and no test file changed.`,
|
|
258
|
+
branch: (b) => `Branch "${b}" carries no issue key.`,
|
|
259
|
+
commitKey: (s) => `Commit subject without an issue key: "${s}".`,
|
|
260
|
+
wip: (s) => `WIP/fixup commit in the branch: "${s}".`,
|
|
261
|
+
rework: (pct, max, days) =>
|
|
262
|
+
`${pct}% of the removed lines were written in the last ${days} days (budget ${max}%). This is rework.`,
|
|
263
|
+
prBody: (n, min) =>
|
|
264
|
+
n === 0 ? "The PR body is empty." : `The PR body is ${n} characters (minimum ${min}).`,
|
|
265
|
+
prTest: "The PR body does not say how the change was tested.",
|
|
266
|
+
hotfix: (n, max) => `Hotfix branch changes ${n} lines (budget ${max}).`,
|
|
267
|
+
age: (d, max) =>
|
|
268
|
+
`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.`,
|
|
269
|
+
behind: (n, d) =>
|
|
270
|
+
`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.`,
|
|
271
|
+
foreign: (s, key) =>
|
|
272
|
+
`Commit "${s}" belongs to another issue (branch key ${key}). Move it to its own PR.`,
|
|
273
|
+
},
|
|
274
|
+
tr: {
|
|
275
|
+
size: (n, max) =>
|
|
276
|
+
`Bu PR ${n} satır değiştiriyor (sınır ${max}). PR'ı zincirleme küçük PR'lara böl.`,
|
|
277
|
+
files: (n, max) =>
|
|
278
|
+
`Bu PR ${n} dosyaya dokunuyor (sınır ${max}). PR'ı zincirleme küçük PR'lara böl.`,
|
|
279
|
+
scope: (r, c) =>
|
|
280
|
+
`${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.`,
|
|
281
|
+
test: (n) => `${n} kaynak dosya değişmiş ama hiçbir test dosyası değişmemiş.`,
|
|
282
|
+
branch: (b) => `"${b}" branch adında Jira ID'si yok.`,
|
|
283
|
+
commitKey: (s) => `Jira ID'si olmayan commit: "${s}".`,
|
|
284
|
+
wip: (s) => `Branch'te WIP/fixup commit var: "${s}".`,
|
|
285
|
+
rework: (pct, max, days) =>
|
|
286
|
+
`Silinen satırların %${pct}'i son ${days} günde yazılmış (sınır %${max}). Bu rework.`,
|
|
287
|
+
prBody: (n, min) =>
|
|
288
|
+
n === 0 ? "PR açıklaması boş." : `PR açıklaması ${n} karakter (en az ${min}).`,
|
|
289
|
+
prTest: "PR açıklaması değişikliğin nasıl test edildiğini söylemiyor.",
|
|
290
|
+
hotfix: (n, max) => `Hotfix branch'i ${n} satır değiştiriyor (sınır ${max}).`,
|
|
291
|
+
age: (d, max) =>
|
|
292
|
+
`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.`,
|
|
293
|
+
behind: (n, d) =>
|
|
294
|
+
`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şı.`,
|
|
295
|
+
foreign: (s, key) =>
|
|
296
|
+
`"${s}" commit'i başka bir işe ait (branch ID'si ${key}). Onu ayrı bir PR'a taşı.`,
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Evaluate every gate rule. Pure: all I/O results arrive in `input`.
|
|
302
|
+
*
|
|
303
|
+
* @param {object} input
|
|
304
|
+
* @param {"dev"|"commit"|"review"} input.mode
|
|
305
|
+
* @param {object[]} input.rules parsed gate_rules, overrides applied
|
|
306
|
+
* @param {ReturnType<typeof measure>} input.metrics
|
|
307
|
+
* @param {string|null} input.branch
|
|
308
|
+
* @param {string[]|null} input.commits subjects, null when unknown
|
|
309
|
+
* @param {string|null} input.prBody null when unknown
|
|
310
|
+
* @param {{ratio:number, recent:number, removed:number}|{reason:string}|null} input.rework
|
|
311
|
+
* @param {{oldestDays:number|null, behind:number|null, baseDays:number|null}|null} [input.history]
|
|
312
|
+
* @param {string} [input.lang]
|
|
313
|
+
*/
|
|
314
|
+
export function evaluate({
|
|
315
|
+
mode,
|
|
316
|
+
rules,
|
|
317
|
+
metrics,
|
|
318
|
+
branch,
|
|
319
|
+
commits,
|
|
320
|
+
prBody,
|
|
321
|
+
rework,
|
|
322
|
+
history = null,
|
|
323
|
+
lang = "en",
|
|
324
|
+
}) {
|
|
325
|
+
const t = messages(TEXT, lang);
|
|
326
|
+
const anchor = anchorFile(metrics.human);
|
|
327
|
+
const results = [];
|
|
328
|
+
const findings = [];
|
|
329
|
+
const add = (rule, severity, issue, file = anchor) =>
|
|
330
|
+
findings.push({
|
|
331
|
+
severity,
|
|
332
|
+
file,
|
|
333
|
+
line: 0,
|
|
334
|
+
issue,
|
|
335
|
+
fix: rule.fix,
|
|
336
|
+
ruleId: rule.id,
|
|
337
|
+
criteriaSource: CRITERIA_SOURCE,
|
|
338
|
+
tag: TAG,
|
|
339
|
+
});
|
|
340
|
+
const record = (rule, status, detail) => results.push({ ruleId: rule.id, status, detail });
|
|
341
|
+
|
|
342
|
+
for (const rule of rules) {
|
|
343
|
+
const modes = Array.isArray(rule.modes) ? rule.modes : [];
|
|
344
|
+
if (!modes.includes(mode)) {
|
|
345
|
+
record(rule, "not-applicable", `not run in ${mode} mode`);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
switch (rule.id) {
|
|
349
|
+
case "VLV-SIZE-01": {
|
|
350
|
+
const n = metrics.changedLines;
|
|
351
|
+
if (n > rule.blocking_over) {
|
|
352
|
+
add(rule, "blocking", t.size(n, rule.important_over));
|
|
353
|
+
record(rule, "violated", `${n} > ${rule.blocking_over}`);
|
|
354
|
+
} else if (n > rule.important_over) {
|
|
355
|
+
add(rule, rule.severity, t.size(n, rule.important_over));
|
|
356
|
+
record(rule, "violated", `${n} > ${rule.important_over}`);
|
|
357
|
+
} else record(rule, "pass", `${n} <= ${rule.important_over}`);
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
360
|
+
case "VLV-SIZE-02": {
|
|
361
|
+
const n = metrics.fileCount;
|
|
362
|
+
if (n > rule.important_over) {
|
|
363
|
+
add(rule, rule.severity, t.files(n, rule.important_over));
|
|
364
|
+
record(rule, "violated", `${n} > ${rule.important_over}`);
|
|
365
|
+
} else record(rule, "pass", `${n} <= ${rule.important_over}`);
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
case "VLV-SCOPE-01": {
|
|
369
|
+
const { renameCount: r, contentLines: c } = metrics;
|
|
370
|
+
if (r >= rule.renames_at_least && c > rule.content_lines_over) {
|
|
371
|
+
add(rule, rule.severity, t.scope(r, c));
|
|
372
|
+
record(rule, "violated", `${r} renames + ${c} content lines`);
|
|
373
|
+
} else record(rule, "pass", `${r} renames, ${c} content lines`);
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
case "VLV-TEST-01": {
|
|
377
|
+
const src = metrics.sourceFiles;
|
|
378
|
+
if (src.length && !metrics.testFiles.length) {
|
|
379
|
+
add(rule, rule.severity, t.test(src.length), src[0]);
|
|
380
|
+
record(rule, "violated", `${src.length} source, 0 test`);
|
|
381
|
+
} else if (!src.length) record(rule, "not-applicable", "no source file changed");
|
|
382
|
+
else record(rule, "pass", `${metrics.testFiles.length} test file(s) changed`);
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
385
|
+
case "VLV-TRACE-01": {
|
|
386
|
+
if (!branch || branch === "HEAD") {
|
|
387
|
+
record(rule, "not-measured", "branch name unknown");
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
if (new RegExp(rule.key_pattern).test(branch)) record(rule, "pass", branch);
|
|
391
|
+
else {
|
|
392
|
+
add(rule, rule.severity, t.branch(branch));
|
|
393
|
+
record(rule, "violated", branch);
|
|
394
|
+
}
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
case "VLV-TRACE-02": {
|
|
398
|
+
if (!commits) {
|
|
399
|
+
record(rule, "not-measured", "commit subjects unknown");
|
|
400
|
+
break;
|
|
401
|
+
}
|
|
402
|
+
const re = new RegExp(rule.key_pattern);
|
|
403
|
+
const bad = commits.filter((s) => !re.test(s));
|
|
404
|
+
for (const s of bad) add(rule, rule.severity, t.commitKey(s));
|
|
405
|
+
record(
|
|
406
|
+
rule,
|
|
407
|
+
bad.length ? "violated" : "pass",
|
|
408
|
+
`${bad.length}/${commits.length} without key`,
|
|
409
|
+
);
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
case "VLV-COMMIT-01": {
|
|
413
|
+
if (!commits) {
|
|
414
|
+
record(rule, "not-measured", "commit subjects unknown");
|
|
415
|
+
break;
|
|
416
|
+
}
|
|
417
|
+
const re = new RegExp(rule.subject_pattern, "i");
|
|
418
|
+
const bad = commits.filter((s) => re.test(s.trim()));
|
|
419
|
+
for (const s of bad) add(rule, rule.severity, t.wip(s));
|
|
420
|
+
record(rule, bad.length ? "violated" : "pass", `${bad.length} WIP/fixup commit(s)`);
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
case "VLV-REWORK-01": {
|
|
424
|
+
if (!rework || "reason" in rework) {
|
|
425
|
+
record(rule, "not-measured", rework?.reason ?? "no history");
|
|
426
|
+
break;
|
|
427
|
+
}
|
|
428
|
+
if (rework.removed < rule.min_removed_lines) {
|
|
429
|
+
record(rule, "pass", `${rework.removed} removed lines, below ${rule.min_removed_lines}`);
|
|
430
|
+
} else if (rework.ratio > rule.ratio_over) {
|
|
431
|
+
const pct = Math.round(rework.ratio * 100);
|
|
432
|
+
add(
|
|
433
|
+
rule,
|
|
434
|
+
rule.severity,
|
|
435
|
+
t.rework(pct, Math.round(rule.ratio_over * 100), rule.window_days),
|
|
436
|
+
);
|
|
437
|
+
record(rule, "violated", `${rework.recent}/${rework.removed}`);
|
|
438
|
+
} else record(rule, "pass", `${rework.recent}/${rework.removed}`);
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
case "VLV-PRDESC-01": {
|
|
442
|
+
if (prBody === null || prBody === undefined) {
|
|
443
|
+
record(rule, "not-measured", "no PR body (branch mode)");
|
|
444
|
+
break;
|
|
445
|
+
}
|
|
446
|
+
const body = prBody.trim();
|
|
447
|
+
const short = body.length < rule.min_chars;
|
|
448
|
+
const untested = !new RegExp(rule.test_pattern, "i").test(body);
|
|
449
|
+
if (short) add(rule, rule.severity, t.prBody(body.length, rule.min_chars));
|
|
450
|
+
if (untested) add(rule, rule.severity, t.prTest);
|
|
451
|
+
record(rule, short || untested ? "violated" : "pass", `${body.length} chars`);
|
|
452
|
+
break;
|
|
453
|
+
}
|
|
454
|
+
case "VLV-AGE-01": {
|
|
455
|
+
const d = history?.oldestDays;
|
|
456
|
+
if (d === null || d === undefined) {
|
|
457
|
+
record(rule, "not-measured", "commit dates unknown");
|
|
458
|
+
} else if (d > rule.days_over) {
|
|
459
|
+
add(rule, rule.severity, t.age(d, rule.days_over));
|
|
460
|
+
record(rule, "violated", `${d} days`);
|
|
461
|
+
} else record(rule, "pass", `${d} days`);
|
|
462
|
+
break;
|
|
463
|
+
}
|
|
464
|
+
case "VLV-BEHIND-01": {
|
|
465
|
+
const n = history?.behind;
|
|
466
|
+
const d = history?.baseDays;
|
|
467
|
+
if (n === null || n === undefined || d === null || d === undefined) {
|
|
468
|
+
record(rule, "not-measured", "base history unknown (no checkout)");
|
|
469
|
+
} else if (n > rule.behind_over || d > rule.base_days_over) {
|
|
470
|
+
add(rule, rule.severity, t.behind(n, d));
|
|
471
|
+
record(rule, "violated", `${n} behind, merge-base ${d} days`);
|
|
472
|
+
} else record(rule, "pass", `${n} behind, merge-base ${d} days`);
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
case "VLV-TRACE-03": {
|
|
476
|
+
const re = new RegExp(rule.key_pattern, "g");
|
|
477
|
+
const own = branch ? (branch.match(re) ?? [])[0] : undefined;
|
|
478
|
+
if (!commits || !own) {
|
|
479
|
+
record(
|
|
480
|
+
rule,
|
|
481
|
+
"not-measured",
|
|
482
|
+
!commits ? "commit subjects unknown" : "branch carries no key",
|
|
483
|
+
);
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
486
|
+
const foreign = commits.filter((c) => {
|
|
487
|
+
const keys = c.match(re) ?? [];
|
|
488
|
+
return keys.length > 0 && !keys.includes(own);
|
|
489
|
+
});
|
|
490
|
+
for (const c of foreign) add(rule, rule.severity, t.foreign(c, own));
|
|
491
|
+
record(rule, foreign.length ? "violated" : "pass", `${foreign.length} foreign commit(s)`);
|
|
492
|
+
break;
|
|
493
|
+
}
|
|
494
|
+
case "VLV-HOTFIX-01": {
|
|
495
|
+
if (!branch || !new RegExp(rule.branch_pattern).test(branch)) {
|
|
496
|
+
record(rule, "not-applicable", "not a hotfix branch");
|
|
497
|
+
break;
|
|
498
|
+
}
|
|
499
|
+
const n = metrics.changedLines;
|
|
500
|
+
if (n > rule.important_over) {
|
|
501
|
+
add(rule, rule.severity, t.hotfix(n, rule.important_over));
|
|
502
|
+
record(rule, "violated", `${n} > ${rule.important_over}`);
|
|
503
|
+
} else record(rule, "pass", `${n} <= ${rule.important_over}`);
|
|
504
|
+
break;
|
|
505
|
+
}
|
|
506
|
+
default:
|
|
507
|
+
record(rule, "not-measured", "rule has no evaluator in this version of the gate");
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const splitRequired = results.some((r) => SPLIT_RULES.has(r.ruleId) && r.status === "violated");
|
|
512
|
+
const failing = findings.some((f) => f.severity === "blocking" || f.severity === "important");
|
|
513
|
+
return { results, findings, splitRequired, failing };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ---------------------------------------------------------------------------
|
|
517
|
+
// I/O
|
|
518
|
+
// ---------------------------------------------------------------------------
|
|
519
|
+
|
|
520
|
+
function git(repo, args) {
|
|
521
|
+
return execFileSync("git", ["-C", repo, ...args], {
|
|
522
|
+
encoding: "utf-8",
|
|
523
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
524
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* One commit line: `<ISO date>\t<subject>` or a bare subject. The dated form is
|
|
530
|
+
* what `git log --format=%aI%x09%s` prints and what a PR caller writes from the
|
|
531
|
+
* provider API; author dates are used so a rebase does not make old work look new.
|
|
532
|
+
*/
|
|
533
|
+
export function parseCommitLine(line) {
|
|
534
|
+
const m = /^(\d{4}-\d{2}-\d{2}T[^\t]+)\t(.*)$/.exec(line);
|
|
535
|
+
if (!m) return { date: null, subject: line };
|
|
536
|
+
const date = new Date(m[1]);
|
|
537
|
+
return { date: Number.isNaN(date.getTime()) ? null : date, subject: m[2] };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Whole days from `from` to `to`, never negative. */
|
|
541
|
+
export function daysBetween(from, to) {
|
|
542
|
+
return Math.max(0, Math.floor((to.getTime() - from.getTime()) / 86400000));
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Removed-line hunks of one file, as [start, count] pairs on the old side. */
|
|
546
|
+
export function removedRanges(unifiedZero) {
|
|
547
|
+
const out = [];
|
|
548
|
+
for (const line of unifiedZero.split("\n")) {
|
|
549
|
+
const m = /^@@ -(\d+)(?:,(\d+))? \+/.exec(line);
|
|
550
|
+
if (!m) continue;
|
|
551
|
+
const count = m[2] === undefined ? 1 : Number(m[2]);
|
|
552
|
+
if (count > 0) out.push([Number(m[1]), count]);
|
|
553
|
+
}
|
|
554
|
+
return out;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** Committer times (epoch seconds) from `git blame --line-porcelain`. */
|
|
558
|
+
export function blameTimes(porcelain) {
|
|
559
|
+
const times = [];
|
|
560
|
+
for (const line of porcelain.split("\n")) {
|
|
561
|
+
const m = /^committer-time (\d+)$/.exec(line);
|
|
562
|
+
if (m) times.push(Number(m[1]));
|
|
563
|
+
}
|
|
564
|
+
return times;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Share of the removed lines that were committed inside the window, blamed at
|
|
569
|
+
* the merge-base so the branch's own commits never count against it.
|
|
570
|
+
*/
|
|
571
|
+
function measureRework(repo, mergeBase, range, human, windowDays, now) {
|
|
572
|
+
const cutoff = Math.floor(now.getTime() / 1000) - windowDays * 86400;
|
|
573
|
+
let removed = 0;
|
|
574
|
+
let recent = 0;
|
|
575
|
+
for (const f of human) {
|
|
576
|
+
if (f.deleted === 0 || f.binary) continue;
|
|
577
|
+
const old = f.oldPath ?? f.path;
|
|
578
|
+
if (f.untracked) continue;
|
|
579
|
+
const diff = git(repo, ["diff", "-U0", "-M", ...range, "--", old, f.path]);
|
|
580
|
+
for (const [start, count] of removedRanges(diff)) {
|
|
581
|
+
const porcelain = git(repo, [
|
|
582
|
+
"blame",
|
|
583
|
+
"--line-porcelain",
|
|
584
|
+
"-L",
|
|
585
|
+
`${start},+${count}`,
|
|
586
|
+
mergeBase,
|
|
587
|
+
"--",
|
|
588
|
+
old,
|
|
589
|
+
]);
|
|
590
|
+
const times = blameTimes(porcelain);
|
|
591
|
+
removed += times.length;
|
|
592
|
+
recent += times.filter((s) => s >= cutoff).length;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return { removed, recent, ratio: removed ? recent / removed : 0 };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** Untracked, not ignored files, counted as fully added. A file holding a NUL byte is binary. */
|
|
599
|
+
function untrackedFiles(repo) {
|
|
600
|
+
return git(repo, ["ls-files", "--others", "--exclude-standard", "-z"])
|
|
601
|
+
.split("\0")
|
|
602
|
+
.filter(Boolean)
|
|
603
|
+
.map((path) => {
|
|
604
|
+
let text;
|
|
605
|
+
try {
|
|
606
|
+
text = readFileSync(join(repo, path), "utf-8");
|
|
607
|
+
} catch {
|
|
608
|
+
text = "";
|
|
609
|
+
}
|
|
610
|
+
const binary = text.includes("\0");
|
|
611
|
+
const added = binary || !text ? 0 : text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
|
|
612
|
+
return {
|
|
613
|
+
path,
|
|
614
|
+
oldPath: null,
|
|
615
|
+
added,
|
|
616
|
+
deleted: 0,
|
|
617
|
+
binary,
|
|
618
|
+
deletedFile: false,
|
|
619
|
+
untracked: true,
|
|
620
|
+
};
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function readPrefs(path) {
|
|
625
|
+
try {
|
|
626
|
+
return JSON.parse(readFileSync(path, "utf-8"))?.global?.valven ?? {};
|
|
627
|
+
} catch {
|
|
628
|
+
return {};
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function parseArgs(argv) {
|
|
633
|
+
const args = {};
|
|
634
|
+
for (let i = 0; i < argv.length; i++) {
|
|
635
|
+
const a = argv[i];
|
|
636
|
+
if (a === "--worktree") {
|
|
637
|
+
args.worktree = true;
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
if (!a.startsWith("--")) throw new Error(`unexpected argument ${a}`);
|
|
641
|
+
const key = a.slice(2);
|
|
642
|
+
const val = argv[i + 1];
|
|
643
|
+
if (val === undefined || val.startsWith("--")) throw new Error(`${a} needs a value`);
|
|
644
|
+
args[key] = val;
|
|
645
|
+
i++;
|
|
646
|
+
}
|
|
647
|
+
return args;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function fail(msg) {
|
|
651
|
+
process.stderr.write(`valven-gate: ${msg}\n`);
|
|
652
|
+
process.exitCode = 2;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
async function main() {
|
|
656
|
+
let args;
|
|
657
|
+
try {
|
|
658
|
+
args = parseArgs(process.argv.slice(2));
|
|
659
|
+
} catch (err) {
|
|
660
|
+
return fail(err.message);
|
|
661
|
+
}
|
|
662
|
+
if (!MODES.has(args.mode)) return fail("--mode must be dev, commit or review");
|
|
663
|
+
|
|
664
|
+
const prefsFile = args.prefs ?? prefsPath();
|
|
665
|
+
const prefs = readPrefs(prefsFile);
|
|
666
|
+
if (prefs.enabled === false) {
|
|
667
|
+
process.stderr.write("valven: disabled by prefs\n");
|
|
668
|
+
const report = { mode: args.mode, enabled: false, results: [], findings: [] };
|
|
669
|
+
if (args.out) writeFileSync(args.out, `${JSON.stringify(report, null, 2)}\n`);
|
|
670
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const rulesPath = args.rules ?? rulesCandidates().find((p) => existsSync(p));
|
|
675
|
+
if (!rulesPath || !existsSync(rulesPath)) {
|
|
676
|
+
return fail(`rules.yml not found (searched ${rulesCandidates().join(", ")})`);
|
|
677
|
+
}
|
|
678
|
+
const { version, rules: parsed } = parseGateRules(readFileSync(rulesPath, "utf-8"));
|
|
679
|
+
if (!parsed.length) return fail(`${rulesPath} has no gate_rules`);
|
|
680
|
+
const rules = applyOverrides(parsed, prefs.thresholds);
|
|
681
|
+
|
|
682
|
+
const { patterns, error: patternError } = loadPatterns(args.patterns ?? DEFAULT_PATTERNS);
|
|
683
|
+
if (patternError) process.stderr.write(`valven-gate: ${patternError}; counting every file\n`);
|
|
684
|
+
|
|
685
|
+
const repo = args.repo ?? process.cwd();
|
|
686
|
+
const head = args.head ?? "HEAD";
|
|
687
|
+
let files;
|
|
688
|
+
let commits = null;
|
|
689
|
+
let branch = args.branch ?? null;
|
|
690
|
+
let rework = { reason: "no checkout (--diff-file input)" };
|
|
691
|
+
let commitDates = [];
|
|
692
|
+
let history = { behind: null, baseDays: null };
|
|
693
|
+
const now = args.now ? new Date(args.now) : new Date();
|
|
694
|
+
|
|
695
|
+
try {
|
|
696
|
+
if (args["diff-file"]) {
|
|
697
|
+
files = parsePatch(readFileSync(args["diff-file"], "utf-8"));
|
|
698
|
+
} else {
|
|
699
|
+
if (!args.base) return fail("--base is required without --diff-file");
|
|
700
|
+
const mergeBase = git(repo, ["merge-base", args.base, head]).trim();
|
|
701
|
+
const range = args.worktree ? [mergeBase] : [mergeBase, head];
|
|
702
|
+
files = parseNumstatZ(git(repo, ["diff", "--numstat", "-z", "-M", ...range]));
|
|
703
|
+
const deleted = new Set(
|
|
704
|
+
git(repo, ["diff", "--name-only", "-z", "--diff-filter=D", ...range])
|
|
705
|
+
.split("\0")
|
|
706
|
+
.filter(Boolean),
|
|
707
|
+
);
|
|
708
|
+
for (const f of files) f.deletedFile = deleted.has(f.path);
|
|
709
|
+
if (args.worktree) files.push(...untrackedFiles(repo));
|
|
710
|
+
const log = git(repo, ["log", "--no-merges", "--format=%aI%x09%s", `${mergeBase}..${head}`])
|
|
711
|
+
.split("\n")
|
|
712
|
+
.filter(Boolean)
|
|
713
|
+
.map(parseCommitLine);
|
|
714
|
+
commits = log.map((c) => c.subject);
|
|
715
|
+
commitDates = log.map((c) => c.date).filter(Boolean);
|
|
716
|
+
const behind = Number(git(repo, ["rev-list", "--count", `${head}..${args.base}`]).trim());
|
|
717
|
+
const baseDate = git(repo, ["log", "-1", "--format=%cI", mergeBase]).trim();
|
|
718
|
+
history = { behind, baseDays: daysBetween(new Date(baseDate), now) };
|
|
719
|
+
if (!branch) branch = git(repo, ["rev-parse", "--abbrev-ref", head]).trim();
|
|
720
|
+
const reworkRule = rules.find((r) => r.id === "VLV-REWORK-01");
|
|
721
|
+
if (reworkRule) {
|
|
722
|
+
const metricsForRework = measure(files, patterns);
|
|
723
|
+
rework = measureRework(
|
|
724
|
+
repo,
|
|
725
|
+
mergeBase,
|
|
726
|
+
range,
|
|
727
|
+
metricsForRework.human,
|
|
728
|
+
reworkRule.window_days,
|
|
729
|
+
now,
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
} catch (err) {
|
|
734
|
+
return fail(`git failed: ${String(err.stderr || err.message).trim()}`);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
if (args["commits-file"]) {
|
|
738
|
+
const log = readFileSync(args["commits-file"], "utf-8")
|
|
739
|
+
.split("\n")
|
|
740
|
+
.map((s) => s.trim())
|
|
741
|
+
.filter(Boolean)
|
|
742
|
+
.map(parseCommitLine);
|
|
743
|
+
commits = log.map((c) => c.subject);
|
|
744
|
+
commitDates = log.map((c) => c.date).filter(Boolean);
|
|
745
|
+
}
|
|
746
|
+
const oldest = commitDates.length ? Math.min(...commitDates.map((d) => d.getTime())) : null;
|
|
747
|
+
history.oldestDays = oldest === null ? null : daysBetween(new Date(oldest), now);
|
|
748
|
+
const prBody = args["pr-body-file"] ? readFileSync(args["pr-body-file"], "utf-8") : null;
|
|
749
|
+
|
|
750
|
+
const metrics = measure(files, patterns);
|
|
751
|
+
const verdict = evaluate({
|
|
752
|
+
mode: args.mode,
|
|
753
|
+
rules,
|
|
754
|
+
metrics,
|
|
755
|
+
branch,
|
|
756
|
+
commits,
|
|
757
|
+
prBody,
|
|
758
|
+
rework,
|
|
759
|
+
history,
|
|
760
|
+
lang: args.lang ?? outputLanguage(prefsFile),
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
const report = {
|
|
764
|
+
mode: args.mode,
|
|
765
|
+
enabled: true,
|
|
766
|
+
rulesVersion: version,
|
|
767
|
+
rulesPath,
|
|
768
|
+
metrics: {
|
|
769
|
+
changedLines: metrics.changedLines,
|
|
770
|
+
fileCount: metrics.fileCount,
|
|
771
|
+
renameCount: metrics.renameCount,
|
|
772
|
+
contentLines: metrics.contentLines,
|
|
773
|
+
sourceFiles: metrics.sourceFiles.length,
|
|
774
|
+
testFiles: metrics.testFiles.length,
|
|
775
|
+
excludedFiles: metrics.excluded.length,
|
|
776
|
+
rework: "reason" in rework ? null : rework,
|
|
777
|
+
},
|
|
778
|
+
results: verdict.results,
|
|
779
|
+
findings: verdict.findings,
|
|
780
|
+
splitRequired: verdict.splitRequired,
|
|
781
|
+
};
|
|
782
|
+
const json = `${JSON.stringify(report, null, 2)}\n`;
|
|
783
|
+
if (args.out) writeFileSync(args.out, json);
|
|
784
|
+
process.stdout.write(json);
|
|
785
|
+
if (verdict.failing) process.exitCode = 1;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
if (invokedDirectly(import.meta.url)) runMain("valven-gate", main, { code: 2 });
|