@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.
Files changed (33) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/docs/facts.json +5 -4
  3. package/install/catalog-history.json +1 -1
  4. package/install/claude.mjs +5 -1
  5. package/install/codex.mjs +4 -3
  6. package/manifest.json +36 -30
  7. package/package.json +1 -1
  8. package/pipeline/commands/multi-agent/review/SKILL.md +42 -1
  9. package/pipeline/multi-agent-refs/features/review-decision.md +2 -1
  10. package/pipeline/multi-agent-refs/features/valven.md +83 -0
  11. package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -0
  12. package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -0
  13. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +2 -4
  14. package/pipeline/multi-agent-refs/phases/phase-3-review.md +5 -5
  15. package/pipeline/multi-agent-refs/phases/phase-4-commit.md +1 -0
  16. package/pipeline/schemas/agent-state.schema.json +16 -0
  17. package/pipeline/schemas/prefs.schema.json +21 -0
  18. package/pipeline/scripts/gen-skills-index.mjs +2 -1
  19. package/pipeline/scripts/phase-tracker.sh +8 -0
  20. package/pipeline/scripts/pre-push-check.sh +3 -1
  21. package/pipeline/scripts/review-decision-gate.mjs +1 -1
  22. package/pipeline/scripts/uninstall.mjs +5 -1
  23. package/pipeline/scripts/valven-gate.mjs +788 -0
  24. package/pipeline/scripts/valven-port.mjs +169 -0
  25. package/pipeline/scripts/valven-split.mjs +341 -0
  26. package/pipeline/scripts/verify-citations.mjs +14 -1
  27. package/pipeline/skills/.skill-manifest.json +7 -3
  28. package/pipeline/skills/.skills-index.json +104 -71
  29. package/pipeline/skills/shared/README.md +4 -3
  30. package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +2 -0
  31. package/pipeline/skills/shared/core/valven-rules/SKILL.md +62 -0
  32. package/pipeline/skills/shared/core/valven-rules/references/rules.yml +140 -0
  33. 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 });