karajan-code 4.57.0 → 4.57.2
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/package.json +1 -1
- package/src/harden/hook-templates.js +14 -1
- package/src/privacy/scan.js +17 -3
- package/src/review/diff-batches.js +90 -0
- package/src/review/split-advice.js +102 -0
- package/src/review/third-party-review.js +46 -13
package/package.json
CHANGED
|
@@ -245,8 +245,21 @@ export function hookBody(hook, cmds = {}, { globalHooksDir = null, baseBranch =
|
|
|
245
245
|
case "post-merge":
|
|
246
246
|
return [
|
|
247
247
|
"# Refresh the local RAG index so retrieval never serves stale code.",
|
|
248
|
+
"# KJC-BUG-0381: detached, so the merge never waits for it (one took 15 minutes),",
|
|
249
|
+
"# and one at a time: a lock in the git dir holding the run's pid, freed only when",
|
|
250
|
+
"# that process is gone (a lock with no pid, after an hour).",
|
|
248
251
|
"if command -v kj >/dev/null 2>&1; then",
|
|
249
|
-
"
|
|
252
|
+
" kj_git_dir=$(git rev-parse --git-dir 2>/dev/null) || kj_git_dir=.git",
|
|
253
|
+
' kj_lock="$kj_git_dir/kj-rag-index.lock"',
|
|
254
|
+
' if [ -d "$kj_lock" ]; then',
|
|
255
|
+
' kj_pid=$(cat "$kj_lock/pid" 2>/dev/null)',
|
|
256
|
+
' if [ -n "$kj_pid" ]; then kill -0 "$kj_pid" 2>/dev/null || rm -rf "$kj_lock"',
|
|
257
|
+
' else find "$kj_lock" -maxdepth 0 -mmin +60 -exec rm -rf {} \\; 2>/dev/null; fi',
|
|
258
|
+
" fi",
|
|
259
|
+
' if mkdir "$kj_lock" 2>/dev/null; then',
|
|
260
|
+
' ( kj rag index --since auto >"$kj_git_dir/kj-rag-index.log" 2>&1; rm -rf "$kj_lock" ) </dev/null >/dev/null 2>&1 &',
|
|
261
|
+
' echo $! >"$kj_lock/pid" 2>/dev/null',
|
|
262
|
+
" fi",
|
|
250
263
|
"fi",
|
|
251
264
|
...chain,
|
|
252
265
|
].join("\n");
|
package/src/privacy/scan.js
CHANGED
|
@@ -81,6 +81,11 @@ const SEMVER_OP = String.raw`(?:[\^~]|[<>]=?|=)`;
|
|
|
81
81
|
const SEMVER_JOIN = String.raw`\s*(?:(?:\|\||-)\s*${SEMVER_OP}?|${SEMVER_OP})\s*`;
|
|
82
82
|
const SEMVER_VALUE = new RegExp(String.raw`^\s*${SEMVER_OP}?\s*${SEMVER_TOKEN}(?:${SEMVER_JOIN}${SEMVER_TOKEN})*\s*$`);
|
|
83
83
|
|
|
84
|
+
/** KJC-BUG-0380: a pair is discarded only when it is a real latitude, longitude. */
|
|
85
|
+
function inGpsRange(_match, lat, lng) {
|
|
86
|
+
return Math.abs(Number(lat)) <= 90 && Math.abs(Number(lng)) <= 180;
|
|
87
|
+
}
|
|
88
|
+
|
|
84
89
|
const CONTEXT_DISCARDS = [
|
|
85
90
|
{ type: "git-sha", re: /\b(?:[0-9a-f]{64}|[0-9a-f]{40})\b/g },
|
|
86
91
|
{ type: "doc-domain-email", re: /\b[A-Za-z0-9._%+-]+@(?:[A-Za-z0-9-]+\.)*(?:example\.(?:com|org|net)|test|invalid|localhost|example)\b/g },
|
|
@@ -114,7 +119,12 @@ const CONTEXT_DISCARDS = [
|
|
|
114
119
|
// lookarounds instead of \b). Only that WHOLE shape with a real 20xx date and
|
|
115
120
|
// time: no card of 14 digits starts with 20 (Diners is 30/36/38), and a
|
|
116
121
|
// 16-digit card does not fit the slot.
|
|
117
|
-
|
|
122
|
+
// KJC-BUG-0380 (#2263): a latitude, longitude pair in test data read as a phone.
|
|
123
|
+
// Only the WHOLE pair: both with a decimal point and four or more decimals, in
|
|
124
|
+
// range (latitude up to 90, longitude up to 180), joined by a comma. A lone
|
|
125
|
+
// number or a pair out of range still warns.
|
|
126
|
+
{ type: "gps-pair", re: /(?<![\d.])(-?\d{1,3}\.\d{4,})\s*,\s*(-?\d{1,3}\.\d{4,})(?![\d.])/g, keep: inGpsRange },
|
|
127
|
+
{ type: "plan-id", re:/(?<![A-Za-z0-9])plan-20\d\d(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])(?:[01]\d|2[0-3])[0-5]\d[0-5]\d-[a-z0-9]{4}(?![A-Za-z0-9])/g },
|
|
118
128
|
];
|
|
119
129
|
|
|
120
130
|
/**
|
|
@@ -160,9 +170,13 @@ export function scanText(text, { list = loadPrivacyList(), source = "<text>" } =
|
|
|
160
170
|
re.lastIndex = m.index + m[0].length;
|
|
161
171
|
}
|
|
162
172
|
}
|
|
163
|
-
for (const { re } of CONTEXT_DISCARDS) {
|
|
173
|
+
for (const { re, keep } of CONTEXT_DISCARDS) {
|
|
164
174
|
re.lastIndex = 0;
|
|
165
|
-
probe = probe.replace(re, (m) => {
|
|
175
|
+
probe = probe.replace(re, (m, ...groups) => {
|
|
176
|
+
if (keep && !keep(m, ...groups)) return m;
|
|
177
|
+
discarded += 1;
|
|
178
|
+
return " ".repeat(m.length);
|
|
179
|
+
});
|
|
166
180
|
}
|
|
167
181
|
const red = redactPII(probe);
|
|
168
182
|
if (red.total > 0) {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* diff-batches (KJC-BUG-0378, #2263): a third-party PR can be far larger than
|
|
3
|
+
* what one reviewer prompt holds (the reviewer clips at DIFF_CLIP_CHARS). The
|
|
4
|
+
* diff is cut at each file and whole files are packed into batches that fit, so
|
|
5
|
+
* every batch is reviewed complete. A file larger than the limit, or files past
|
|
6
|
+
* the batch cap, are not reviewed and are named in the coverage, never hidden.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const FILE_HEAD = /^diff --git a\/(.+?) b\//;
|
|
10
|
+
|
|
11
|
+
/** The diff cut at each file, with the number of changed lines in each. */
|
|
12
|
+
export function splitByFile(diff) {
|
|
13
|
+
const parts = [];
|
|
14
|
+
for (const line of String(diff).split("\n")) {
|
|
15
|
+
const head = FILE_HEAD.exec(line);
|
|
16
|
+
if (head) parts.push({ file: head[1], lines: [] });
|
|
17
|
+
if (parts.length === 0) continue;
|
|
18
|
+
parts.at(-1).lines.push(line);
|
|
19
|
+
}
|
|
20
|
+
return parts.map(({ file, lines }) => ({ file, text: lines.join("\n"), changed: changedLines(lines) }));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const changedLines = (lines) => lines.filter((l) => /^[+-]/.test(l) && !/^(\+\+\+|---) /.test(l)).length;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Whole files packed into batches of at most `limit` characters, at most
|
|
27
|
+
* `maxBatches` of them, and the coverage that leaves.
|
|
28
|
+
*/
|
|
29
|
+
export function batchDiff(diff, { limit, maxBatches }) {
|
|
30
|
+
const parts = splitByFile(diff);
|
|
31
|
+
const batches = [];
|
|
32
|
+
const left = [];
|
|
33
|
+
for (const piece of parts.flatMap((part) => piecesOf(part, limit))) {
|
|
34
|
+
const last = batches.at(-1);
|
|
35
|
+
if (piece.text.length > limit) left.push(piece);
|
|
36
|
+
else if (last && last.text.length + 1 + piece.text.length <= limit) last.add(piece);
|
|
37
|
+
else if (batches.length < maxBatches) batches.push(newBatch(piece));
|
|
38
|
+
else left.push(piece);
|
|
39
|
+
}
|
|
40
|
+
// A file counts as reviewed only when every piece of it was.
|
|
41
|
+
const missing = new Set(left.map((p) => p.file));
|
|
42
|
+
const sum = (ps) => ps.reduce((n, p) => n + p.changed, 0);
|
|
43
|
+
const reviewedLines = sum(batches.flatMap((b) => b.parts));
|
|
44
|
+
return {
|
|
45
|
+
batches: batches.map((b) => ({ files: [...new Set(b.parts.map((p) => p.file))], text: b.text })),
|
|
46
|
+
coverage: {
|
|
47
|
+
files: { reviewed: parts.length - missing.size, total: parts.length },
|
|
48
|
+
lines: { reviewed: reviewedLines, total: reviewedLines + sum(left) },
|
|
49
|
+
truncated: missing.size > 0,
|
|
50
|
+
unreviewed: [...missing],
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* KJC-BUG-0382 (#2263): a file too large for one batch is cut into pieces of whole
|
|
57
|
+
* hunks, each with the file's header, so it is reviewed whole. A single hunk larger
|
|
58
|
+
* than the limit stays one piece and is left out (named in the coverage).
|
|
59
|
+
*/
|
|
60
|
+
function piecesOf(part, limit) {
|
|
61
|
+
if (part.text.length <= limit) return [part];
|
|
62
|
+
const lines = part.text.split("\n");
|
|
63
|
+
const firstHunk = lines.findIndex((l) => l.startsWith("@@"));
|
|
64
|
+
if (firstHunk === -1) return [part];
|
|
65
|
+
const header = lines.slice(0, firstHunk).join("\n");
|
|
66
|
+
const hunks = [];
|
|
67
|
+
for (const line of lines.slice(firstHunk)) {
|
|
68
|
+
if (line.startsWith("@@")) hunks.push([]);
|
|
69
|
+
hunks.at(-1).push(line);
|
|
70
|
+
}
|
|
71
|
+
const pieces = [];
|
|
72
|
+
for (const hunk of hunks.map((h) => h.join("\n"))) {
|
|
73
|
+
const last = pieces.at(-1);
|
|
74
|
+
if (last && last.text.length + 1 + hunk.length <= limit) last.text = `${last.text}\n${hunk}`;
|
|
75
|
+
else pieces.push({ file: part.file, text: `${header}\n${hunk}` });
|
|
76
|
+
}
|
|
77
|
+
return pieces.map((p) => ({ ...p, changed: changedLines(p.text.split("\n")) }));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function newBatch(first) {
|
|
81
|
+
const batch = {
|
|
82
|
+
parts: [first],
|
|
83
|
+
text: first.text,
|
|
84
|
+
add(part) {
|
|
85
|
+
batch.parts.push(part);
|
|
86
|
+
batch.text = `${batch.text}\n${part.text}`;
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
return batch;
|
|
90
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* split-advice (KJC-TSK-1101, #2263): how to split a large third-party PR. Zero
|
|
3
|
+
* tokens and reproducible: files are grouped by the project's real structure (the
|
|
4
|
+
* Java/Kotlin package under the common prefix, the folder under src/ in JS/TS,
|
|
5
|
+
* tests and resources apart), a group still too large is split by its next level,
|
|
6
|
+
* and chunks are ordered by layer so each one builds on the ones before it. It is
|
|
7
|
+
* advice: it never counts as a finding and never changes the verdict.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const LAYERS = [
|
|
11
|
+
{ rank: 0, names: ["config"], purpose: "build and project configuration" },
|
|
12
|
+
{ rank: 1, names: ["resources"], purpose: "configuration and data files the code reads" },
|
|
13
|
+
{ rank: 2, names: ["model", "models", "domain", "entity", "entities", "dto", "types"], purpose: "the data types the rest builds on" },
|
|
14
|
+
{ rank: 3, names: ["utils", "util", "common", "helpers", "core", "support", "shared"], purpose: "shared helpers used by the layers above" },
|
|
15
|
+
{ rank: 4, names: ["api", "client", "clients", "services", "service", "repository", "data"], purpose: "access to services and APIs" },
|
|
16
|
+
{ rank: 5, names: ["pages", "page", "components", "ui", "views", "screens"], purpose: "what the user sees: pages and components" },
|
|
17
|
+
{ rank: 6, names: ["flows", "flow", "steps", "scenarios", "features", "workflows"], purpose: "flows that combine pages and services" },
|
|
18
|
+
{ rank: 8, names: ["tests"], purpose: "the tests that exercise the chunks above" },
|
|
19
|
+
];
|
|
20
|
+
const OTHER_RANK = 7;
|
|
21
|
+
|
|
22
|
+
const isTest = (f) => /(^|\/)src\/test\/|(^|\/)(tests?|__tests__|e2e|spec)\/|\.(test|spec)\.[a-z]+$/.test(f);
|
|
23
|
+
const dirsOf = (path) => path.split("/").slice(0, -1);
|
|
24
|
+
|
|
25
|
+
function commonPrefix(lists) {
|
|
26
|
+
if (lists.length === 0) return [];
|
|
27
|
+
let n = Math.min(...lists.map((l) => l.length));
|
|
28
|
+
for (let i = 0; i < n; i++) if (!lists.every((l) => l[i] === lists[0][i])) n = i;
|
|
29
|
+
return lists[0].slice(0, n);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The structural path of each file: the segments that tell its responsibility. */
|
|
33
|
+
function keysOf(files) {
|
|
34
|
+
const javaMain = /(^|\/)src\/main\/(java|kotlin)\/(.+)$/;
|
|
35
|
+
const javaDirs = files.map((f) => javaMain.exec(f.file)).filter(Boolean).map((m) => dirsOf(m[3]));
|
|
36
|
+
// The root package (com/acme/app), never a layer: a PR that only touches pages/ keeps "pages".
|
|
37
|
+
const common = commonPrefix(javaDirs);
|
|
38
|
+
const layerAt = common.findIndex((seg) => layerOf(seg));
|
|
39
|
+
const prefix = layerAt === -1 ? common : common.slice(0, layerAt);
|
|
40
|
+
return files.map(({ file }) => {
|
|
41
|
+
if (/(^|\/)src\/(main|test)\/resources\//.test(file)) return ["resources"];
|
|
42
|
+
if (isTest(file)) return ["tests"];
|
|
43
|
+
const java = javaMain.exec(file);
|
|
44
|
+
if (java) {
|
|
45
|
+
const rest = dirsOf(java[3]).slice(prefix.length);
|
|
46
|
+
return rest.length > 0 ? rest : ["main"];
|
|
47
|
+
}
|
|
48
|
+
const src = /(^|\/)src\/(.+)$/.exec(file);
|
|
49
|
+
if (src && dirsOf(src[2]).length > 0) return dirsOf(src[2]);
|
|
50
|
+
const dirs = dirsOf(file);
|
|
51
|
+
return dirs.length === 0 || dirs[0].startsWith(".") ? ["config"] : dirs;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function group(entries, depth) {
|
|
56
|
+
const groups = new Map();
|
|
57
|
+
for (const e of entries) {
|
|
58
|
+
const name = e.key.slice(0, depth).join("/");
|
|
59
|
+
groups.set(name, [...(groups.get(name) ?? []), e]);
|
|
60
|
+
}
|
|
61
|
+
return [...groups].map(([name, items]) => ({ name, items, lines: items.reduce((n, i) => n + i.changed, 0), depth }));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Groups by the first segment, then splits any group over `limit` by its next level. */
|
|
65
|
+
function chunksOf(entries, limit) {
|
|
66
|
+
const out = [];
|
|
67
|
+
const pending = group(entries, 1);
|
|
68
|
+
while (pending.length > 0) {
|
|
69
|
+
const g = pending.shift();
|
|
70
|
+
const deeper = g.items.some((i) => i.key.length > g.depth);
|
|
71
|
+
if (g.lines > limit && deeper) pending.push(...group(g.items, g.depth + 1));
|
|
72
|
+
else out.push(g);
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const layerOf = (name) => LAYERS.find((l) => l.names.includes(name.split("/")[0])) ?? null;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {{file: string, changed: number}[]} files the PR's files and changed lines
|
|
81
|
+
* @param {{limit: number}} opts the size one chunk should stay under
|
|
82
|
+
*/
|
|
83
|
+
export function splitAdvice(files, { limit }) {
|
|
84
|
+
const keys = keysOf(files);
|
|
85
|
+
const entries = files.map((f, i) => ({ ...f, key: keys[i] }));
|
|
86
|
+
const total = files.reduce((n, f) => n + f.changed, 0);
|
|
87
|
+
const chunks = chunksOf(entries, limit)
|
|
88
|
+
.map((g) => ({ ...g, rank: layerOf(g.name)?.rank ?? OTHER_RANK }))
|
|
89
|
+
.sort((a, b) => a.rank - b.rank || a.name.localeCompare(b.name))
|
|
90
|
+
.map((g, i) => ({
|
|
91
|
+
order: i + 1,
|
|
92
|
+
name: g.name,
|
|
93
|
+
purpose: layerOf(g.name)?.purpose ?? `changes under ${g.name}`,
|
|
94
|
+
files: g.items.map((e) => e.file),
|
|
95
|
+
lines: g.lines,
|
|
96
|
+
}));
|
|
97
|
+
return {
|
|
98
|
+
why: `This PR changes ${files.length} files and ${total} lines. Past a few hundred lines a reviewer misses more and the review takes longer; PRs with one purpose each get a better review and merge sooner.`,
|
|
99
|
+
order: "Each chunk builds on the ones before it (configuration, data types, helpers, services, pages, flows, tests), so it can compile and pass its tests on its own.",
|
|
100
|
+
chunks,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -8,9 +8,16 @@
|
|
|
8
8
|
import { runCommand } from "../utils/process.js";
|
|
9
9
|
import { resolveRole } from "../config/role-resolver.js";
|
|
10
10
|
import { runOneShotReview } from "./one-shot-review.js";
|
|
11
|
-
import { prRequirementChecks } from "./pr-requirements.js";
|
|
11
|
+
import { prRequirementChecks, prSizeLimit } from "./pr-requirements.js";
|
|
12
|
+
import { splitAdvice } from "./split-advice.js";
|
|
13
|
+
import { isGenerated } from "./loc-budget.js";
|
|
12
14
|
import { prSecurityChecks } from "./pr-security.js";
|
|
13
15
|
import { sonarCheck } from "./pr-sonar.js";
|
|
16
|
+
import { batchDiff, splitByFile } from "./diff-batches.js";
|
|
17
|
+
import { DIFF_CLIP_CHARS } from "../prompts/diff-clip.js";
|
|
18
|
+
|
|
19
|
+
// KJC-BUG-0378: a reviewer call per batch; past this many, files are left out and named.
|
|
20
|
+
const MAX_REVIEW_BATCHES = 10;
|
|
14
21
|
|
|
15
22
|
const gitDiffOf = async (projectDir, range) => {
|
|
16
23
|
const res = await runCommand("git", ["diff", range], { cwd: projectDir });
|
|
@@ -23,27 +30,53 @@ const finding = (i) => ({
|
|
|
23
30
|
description: i.description ?? "", suggested_fix: i.suggested_fix ?? null,
|
|
24
31
|
});
|
|
25
32
|
|
|
26
|
-
/** The review as data: verdict, findings, suggestions and the
|
|
27
|
-
export async function thirdPartyReview({ range, config, projectDir, logger, task = null, gitDiff = gitDiffOf, run = runOneShotReview, requirements = prRequirementChecks, security = prSecurityChecks, sonar = sonarCheck }) {
|
|
33
|
+
/** The review as data: verdict, findings, suggestions, security checks and, apart, the house requirements. */
|
|
34
|
+
export async function thirdPartyReview({ range, config, projectDir, logger, task = null, gitDiff = gitDiffOf, run = runOneShotReview, requirements = prRequirementChecks, security = prSecurityChecks, sonar = sonarCheck, limit = DIFF_CLIP_CHARS, maxBatches = MAX_REVIEW_BATCHES, sizeLimit = null }) {
|
|
28
35
|
const diff = await gitDiff(projectDir, range);
|
|
29
36
|
// KJC-TSK-1076/1077: the PR's requirements and its security, with no tokens; they never stop the review.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
37
|
+
// KJC-BUG-0379 (#2263): the house requirements (size, commits, attribution, tests) travel apart:
|
|
38
|
+
// on another repo with other conventions they must not read as findings next to security.
|
|
39
|
+
const policyChecks = await requirements({ range, projectDir, config });
|
|
40
|
+
const checks = [...await security({ diff, projectDir, config }), await sonar({ diff, config, projectDir })];
|
|
41
|
+
// KJC-BUG-0378 (#2263): the whole diff, in batches of whole files that fit the reviewer's
|
|
42
|
+
// prompt; what does not fit is named in the coverage and the verdict cannot be approved.
|
|
43
|
+
const provider = resolveRole(config, "reviewer").provider || null;
|
|
44
|
+
const { batches, coverage } = batchDiff(diff, { limit, maxBatches });
|
|
45
|
+
if (coverage.files.total === 0) throw new Error(`nothing to review — ${range} has no file changes`);
|
|
46
|
+
const records = [];
|
|
47
|
+
for (const batch of batches) {
|
|
48
|
+
records.push(await run({ diff: batch.text, task, config, logger, projectDir, reviewerOverride: provider, persist: false }));
|
|
49
|
+
}
|
|
36
50
|
return {
|
|
37
51
|
mode: "third-party",
|
|
38
52
|
range,
|
|
39
|
-
reviewer:
|
|
40
|
-
verdict:
|
|
41
|
-
|
|
42
|
-
|
|
53
|
+
reviewer: records[0]?.reviewer ?? provider,
|
|
54
|
+
verdict: verdictOf(records, coverage),
|
|
55
|
+
coverage,
|
|
56
|
+
findings: records.flatMap((r) => (r.issues ?? []).map(finding)),
|
|
57
|
+
suggestions: records.flatMap((r) => r.suggestions ?? []),
|
|
43
58
|
checks,
|
|
59
|
+
policyChecks,
|
|
60
|
+
advice: adviceFor(diff, sizeLimit ?? prSizeLimit({ projectDir, config }).limit),
|
|
44
61
|
};
|
|
45
62
|
}
|
|
46
63
|
|
|
64
|
+
/** KJC-TSK-1101: over the size budget, how to split the PR; advice only, never a finding. */
|
|
65
|
+
function adviceFor(diff, budget) {
|
|
66
|
+
const files = splitByFile(diff).filter((f) => !isGenerated(f.file));
|
|
67
|
+
const total = files.reduce((n, f) => n + f.changed, 0);
|
|
68
|
+
return total > budget ? { split: splitAdvice(files, { limit: budget }) } : {};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Rejected if any batch was; approved only when every file was reviewed; partial otherwise. */
|
|
72
|
+
function verdictOf(records, coverage) {
|
|
73
|
+
let status = "approved";
|
|
74
|
+
if (records.some((r) => r.verdict !== "approved")) status = "rejected";
|
|
75
|
+
else if (coverage.truncated) status = "partial";
|
|
76
|
+
const summary = records.map((r) => r.summary).filter(Boolean).join("\n");
|
|
77
|
+
return { status, approved: status === "approved", summary };
|
|
78
|
+
}
|
|
79
|
+
|
|
47
80
|
/**
|
|
48
81
|
* The CLI side: stdout carries exactly one JSON document, logs go to stderr.
|
|
49
82
|
* Exit 0 whatever the PR's verdict (the agent decides what to post); non-zero
|