karajan-code 4.57.1 → 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
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");
|
|
@@ -17,40 +17,66 @@ export function splitByFile(diff) {
|
|
|
17
17
|
if (parts.length === 0) continue;
|
|
18
18
|
parts.at(-1).lines.push(line);
|
|
19
19
|
}
|
|
20
|
-
return parts.map(({ file, lines }) => ({
|
|
21
|
-
file,
|
|
22
|
-
text: lines.join("\n"),
|
|
23
|
-
changed: lines.filter((l) => /^[+-]/.test(l) && !/^(\+\+\+|---) /.test(l)).length,
|
|
24
|
-
}));
|
|
20
|
+
return parts.map(({ file, lines }) => ({ file, text: lines.join("\n"), changed: changedLines(lines) }));
|
|
25
21
|
}
|
|
26
22
|
|
|
23
|
+
const changedLines = (lines) => lines.filter((l) => /^[+-]/.test(l) && !/^(\+\+\+|---) /.test(l)).length;
|
|
24
|
+
|
|
27
25
|
/**
|
|
28
26
|
* Whole files packed into batches of at most `limit` characters, at most
|
|
29
27
|
* `maxBatches` of them, and the coverage that leaves.
|
|
30
28
|
*/
|
|
31
29
|
export function batchDiff(diff, { limit, maxBatches }) {
|
|
30
|
+
const parts = splitByFile(diff);
|
|
32
31
|
const batches = [];
|
|
33
|
-
const
|
|
34
|
-
for (const
|
|
32
|
+
const left = [];
|
|
33
|
+
for (const piece of parts.flatMap((part) => piecesOf(part, limit))) {
|
|
35
34
|
const last = batches.at(-1);
|
|
36
|
-
if (
|
|
37
|
-
else if (last && last.text.length + 1 +
|
|
38
|
-
else if (batches.length < maxBatches) batches.push(newBatch(
|
|
39
|
-
else
|
|
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);
|
|
40
39
|
}
|
|
41
|
-
|
|
40
|
+
// A file counts as reviewed only when every piece of it was.
|
|
41
|
+
const missing = new Set(left.map((p) => p.file));
|
|
42
42
|
const sum = (ps) => ps.reduce((n, p) => n + p.changed, 0);
|
|
43
|
+
const reviewedLines = sum(batches.flatMap((b) => b.parts));
|
|
43
44
|
return {
|
|
44
|
-
batches: batches.map((b) => ({ files: b.parts.map((p) => p.file), text: b.text })),
|
|
45
|
+
batches: batches.map((b) => ({ files: [...new Set(b.parts.map((p) => p.file))], text: b.text })),
|
|
45
46
|
coverage: {
|
|
46
|
-
files: { reviewed:
|
|
47
|
-
lines: { reviewed:
|
|
48
|
-
truncated:
|
|
49
|
-
unreviewed:
|
|
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],
|
|
50
51
|
},
|
|
51
52
|
};
|
|
52
53
|
}
|
|
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
|
+
|
|
54
80
|
function newBatch(first) {
|
|
55
81
|
const batch = {
|
|
56
82
|
parts: [first],
|
|
@@ -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,10 +8,12 @@
|
|
|
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";
|
|
14
|
-
import { batchDiff } from "./diff-batches.js";
|
|
16
|
+
import { batchDiff, splitByFile } from "./diff-batches.js";
|
|
15
17
|
import { DIFF_CLIP_CHARS } from "../prompts/diff-clip.js";
|
|
16
18
|
|
|
17
19
|
// KJC-BUG-0378: a reviewer call per batch; past this many, files are left out and named.
|
|
@@ -29,7 +31,7 @@ const finding = (i) => ({
|
|
|
29
31
|
});
|
|
30
32
|
|
|
31
33
|
/** The review as data: verdict, findings, suggestions, security checks and, apart, the house requirements. */
|
|
32
|
-
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 }) {
|
|
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 }) {
|
|
33
35
|
const diff = await gitDiff(projectDir, range);
|
|
34
36
|
// KJC-TSK-1076/1077: the PR's requirements and its security, with no tokens; they never stop the review.
|
|
35
37
|
// KJC-BUG-0379 (#2263): the house requirements (size, commits, attribution, tests) travel apart:
|
|
@@ -55,9 +57,17 @@ export async function thirdPartyReview({ range, config, projectDir, logger, task
|
|
|
55
57
|
suggestions: records.flatMap((r) => r.suggestions ?? []),
|
|
56
58
|
checks,
|
|
57
59
|
policyChecks,
|
|
60
|
+
advice: adviceFor(diff, sizeLimit ?? prSizeLimit({ projectDir, config }).limit),
|
|
58
61
|
};
|
|
59
62
|
}
|
|
60
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
|
+
|
|
61
71
|
/** Rejected if any batch was; approved only when every file was reviewed; partial otherwise. */
|
|
62
72
|
function verdictOf(records, coverage) {
|
|
63
73
|
let status = "approved";
|