@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.
Files changed (67) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +6 -5
  3. package/README.tr.md +6 -5
  4. package/docs/architecture.md +3 -3
  5. package/docs/ecosystem.md +5 -5
  6. package/docs/facts.json +14 -12
  7. package/docs/features.md +9 -1
  8. package/install/catalog-history.json +1 -1
  9. package/install/claude.mjs +40 -1
  10. package/install/codex.mjs +4 -3
  11. package/manifest.json +70 -51
  12. package/package.json +1 -1
  13. package/pipeline/commands/multi-agent/bug-bash/SKILL.md +154 -0
  14. package/pipeline/commands/multi-agent/review/SKILL.md +46 -1
  15. package/pipeline/commands/multi-agent/setup/SKILL.md +8 -6
  16. package/pipeline/commands/multi-agent/sync/SKILL.md +4 -3
  17. package/pipeline/commands/multi-agent/update/SKILL.md +5 -1
  18. package/pipeline/commands/sim-test.md +71 -6
  19. package/pipeline/lib/plugin-skill-roots.mjs +80 -0
  20. package/pipeline/lib/usage-lifecycle.mjs +83 -0
  21. package/pipeline/multi-agent-refs/cross-cli-contract.md +20 -4
  22. package/pipeline/multi-agent-refs/features/assertion-judge.md +76 -0
  23. package/pipeline/multi-agent-refs/features/exploratory-testing.md +140 -0
  24. package/pipeline/multi-agent-refs/features/review-decision.md +2 -1
  25. package/pipeline/multi-agent-refs/features/skill-conformance.md +1 -1
  26. package/pipeline/multi-agent-refs/features/usage-reporting.md +59 -5
  27. package/pipeline/multi-agent-refs/features/valven.md +110 -0
  28. package/pipeline/multi-agent-refs/help/en.md +2 -0
  29. package/pipeline/multi-agent-refs/help/tr.md +2 -0
  30. package/pipeline/multi-agent-refs/phases/phase-0-init.md +2 -1
  31. package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -0
  32. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +2 -4
  33. package/pipeline/multi-agent-refs/phases/phase-3-review.md +5 -5
  34. package/pipeline/multi-agent-refs/phases/phase-4-commit.md +3 -2
  35. package/pipeline/schemas/agent-state.schema.json +16 -0
  36. package/pipeline/schemas/bug-bash-verification.schema.json +41 -0
  37. package/pipeline/schemas/explore-run.schema.json +108 -0
  38. package/pipeline/schemas/prefs.schema.json +21 -0
  39. package/pipeline/scripts/_stack-routing.mjs +2 -0
  40. package/pipeline/scripts/bug-bash.mjs +379 -0
  41. package/pipeline/scripts/explore-findings.mjs +410 -0
  42. package/pipeline/scripts/pr-timeline.mjs +135 -0
  43. package/pipeline/scripts/pre-commit-check.sh +4 -2
  44. package/pipeline/scripts/review-decision-gate.mjs +1 -1
  45. package/pipeline/scripts/skill-conformance.mjs +17 -71
  46. package/pipeline/scripts/smoke-cross-cli-behavior.sh +1 -1
  47. package/pipeline/scripts/uninstall.mjs +28 -3
  48. package/pipeline/scripts/usage-identity.mjs +36 -10
  49. package/pipeline/scripts/usage-register.mjs +95 -8
  50. package/pipeline/scripts/usage-report.mjs +52 -2
  51. package/pipeline/scripts/valven-gate.mjs +1069 -0
  52. package/pipeline/scripts/valven-port.mjs +169 -0
  53. package/pipeline/scripts/valven-split.mjs +341 -0
  54. package/pipeline/scripts/verify-citations.mjs +14 -1
  55. package/pipeline/skills/.skill-manifest.json +18 -6
  56. package/pipeline/skills/.skills-index.json +126 -71
  57. package/pipeline/skills/shared/README.md +8 -5
  58. package/pipeline/skills/shared/core/multi-agent-bug-bash/SKILL.md +150 -0
  59. package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +2 -0
  60. package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +5 -1
  61. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +4 -3
  62. package/pipeline/skills/shared/core/multi-agent-update/SKILL.md +4 -1
  63. package/pipeline/skills/shared/external/e2e-testing/SKILL.md +124 -0
  64. package/pipeline/skills/shared/external/e2e-testing/references/document-fixtures.md +65 -0
  65. package/pipeline/skills/shared/external/valven-rules/SKILL.md +93 -0
  66. package/pipeline/skills/shared/external/valven-rules/references/rules.yml +186 -0
  67. 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 });