karajan-code 2.34.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +120 -38
- package/bin/kj-rag-mcp.js +75 -0
- package/docs/README.es.md +5 -5
- package/package.json +22 -17
- package/packages/hu-board/package.json +5 -2
- package/packages/hu-board/public/app.js +81 -4025
- package/packages/hu-board/public/index.html +20 -0
- package/packages/hu-board/public/utils/api.js +139 -0
- package/packages/hu-board/public/utils/board-view.js +453 -0
- package/packages/hu-board/public/utils/command-launcher.js +213 -0
- package/packages/hu-board/public/utils/config-editor.js +181 -0
- package/packages/hu-board/public/utils/dashboard-view.js +103 -0
- package/packages/hu-board/public/utils/formatters.js +232 -0
- package/packages/hu-board/public/utils/graph-view.js +216 -0
- package/packages/hu-board/public/utils/hu-actions.js +224 -0
- package/packages/hu-board/public/utils/init-listeners.js +180 -0
- package/packages/hu-board/public/utils/log-panel.js +222 -0
- package/packages/hu-board/public/utils/modals.js +290 -0
- package/packages/hu-board/public/utils/plan-rollup-view.js +53 -0
- package/packages/hu-board/public/utils/preflight-view.js +89 -0
- package/packages/hu-board/public/utils/project-actions.js +143 -0
- package/packages/hu-board/public/utils/project-picker-view.js +139 -0
- package/packages/hu-board/public/utils/run-launcher.js +169 -0
- package/packages/hu-board/public/utils/server-push.js +252 -0
- package/packages/hu-board/public/utils/sessions-view.js +199 -0
- package/packages/hu-board/public/utils/story-detail-view.js +324 -0
- package/packages/hu-board/public/utils/story-edit-form.js +271 -0
- package/packages/hu-board/src/config-yaml.js +14 -0
- package/packages/hu-board/src/db.js +15 -2
- package/packages/hu-board/src/routes/api.js +27 -9
- package/scripts/audit-test-diet.mjs +151 -0
- package/src/audit/deterministic-summary.js +20 -0
- package/src/audit/harness-scorecard.js +52 -3
- package/src/audit/harness-section.js +6 -2
- package/src/audit/injection-findings.js +105 -0
- package/src/checks/hardware.js +150 -0
- package/src/checks/node.js +9 -10
- package/src/checks/runner.js +10 -0
- package/src/cli/register-meta.js +48 -2
- package/src/cli/register-plan.js +10 -1
- package/src/commands/clean.js +62 -0
- package/src/commands/doctor.js +2 -0
- package/src/commands/init.js +41 -1
- package/src/commands/plan/generate.js +9 -3
- package/src/commands/rag-mcp.js +18 -0
- package/src/commands/sync.js +30 -2
- package/src/commands/telemetry.js +70 -0
- package/src/config/defaults.js +9 -1
- package/src/config/schema.js +2 -0
- package/src/orchestrator/config-init.js +1 -0
- package/src/orchestrator/drivers/iteration-phases/quality-gates.js +23 -0
- package/src/orchestrator/stages/coder-stage.js +11 -2
- package/src/orchestrator/stages/reviewer-stage.js +11 -0
- package/src/orchestrator/stages/tdd-discipline-stage.js +56 -0
- package/src/orchestrator/stages/tool-judge-stage.js +53 -0
- package/src/prompts/tool-correctness-judge.js +31 -0
- package/src/review/git-stash-helper.js +104 -0
- package/src/review/tdd-discipline.js +90 -0
- package/src/roles/audit-role.js +9 -1
- package/src/roles/tool-correctness-judge-role.js +78 -0
- package/src/session/mutators.js +4 -0
- package/src/sonar/scanner.js +51 -16
- package/src/sync/apply-canvas-patch.js +55 -0
- package/src/utils/paths.js +12 -0
- package/src/utils/quota-simulator.js +56 -0
- package/src/utils/repo-cleaner.js +79 -0
- package/src/utils/telemetry.js +34 -12
- package/src/utils/tool-call-extractor.js +56 -0
- package/src/utils/vector-store-orphans.js +89 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Surgical stash helper for the TDD discipline gate (KJC-TSK-0398 PR2).
|
|
2
|
+
// Stashes only the source files we want to hide for the "red" run and
|
|
3
|
+
// restores them afterwards. Tracked files use `git stash push -- <paths>`;
|
|
4
|
+
// untracked files (not yet known to git) are moved aside to a private
|
|
5
|
+
// `.karajan/tmp/stash-<id>/` tree because `git stash` would ignore them.
|
|
6
|
+
|
|
7
|
+
import { mkdir, rename, rm } from "node:fs/promises";
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { dirname, join, resolve } from "node:path";
|
|
10
|
+
import { runCommand } from "../utils/process.js";
|
|
11
|
+
|
|
12
|
+
function defaultRunner(cmd, args, opts) {
|
|
13
|
+
return runCommand(cmd, args, opts);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function isTracked(runner, projectDir, file) {
|
|
17
|
+
const res = await runner("git", ["ls-files", "--error-unmatch", "--", file], { cwd: projectDir });
|
|
18
|
+
return res.exitCode === 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function stashRefForToken(runner, projectDir, marker) {
|
|
22
|
+
const res = await runner("git", ["stash", "list"], { cwd: projectDir });
|
|
23
|
+
if (res.exitCode !== 0) return null;
|
|
24
|
+
for (const line of String(res.stdout || "").split("\n")) {
|
|
25
|
+
if (line.includes(marker)) {
|
|
26
|
+
const m = line.match(/^(stash@\{\d+\})/);
|
|
27
|
+
if (m) return m[1];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Move `files` aside so a test run sees them as missing. Returns a token to
|
|
34
|
+
// pass to `restoreFiles()`. Throws on failure with the partial token attached.
|
|
35
|
+
export async function stashFilesAside(files, { projectDir = process.cwd(), runner = defaultRunner, tmpRoot } = {}) {
|
|
36
|
+
if (!Array.isArray(files) || files.length === 0) {
|
|
37
|
+
return { trackedStashRef: null, untrackedMoves: [], marker: null };
|
|
38
|
+
}
|
|
39
|
+
const tracked = [];
|
|
40
|
+
const untracked = [];
|
|
41
|
+
for (const f of files) {
|
|
42
|
+
if (await isTracked(runner, projectDir, f)) tracked.push(f);
|
|
43
|
+
else if (existsSync(resolve(projectDir, f))) untracked.push(f);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const marker = `kj-tdd-discipline-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
47
|
+
const stashRoot = tmpRoot || join(projectDir, ".karajan", "tmp", marker);
|
|
48
|
+
const untrackedMoves = [];
|
|
49
|
+
let trackedStashRef = null;
|
|
50
|
+
|
|
51
|
+
if (tracked.length > 0) {
|
|
52
|
+
const args = ["stash", "push", "--keep-index", "-m", marker, "--", ...tracked];
|
|
53
|
+
const res = await runner("git", args, { cwd: projectDir });
|
|
54
|
+
if (res.exitCode !== 0) {
|
|
55
|
+
throw new Error(`git stash push failed: ${res.stderr || res.stdout}`);
|
|
56
|
+
}
|
|
57
|
+
trackedStashRef = await stashRefForToken(runner, projectDir, marker);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const f of untracked) {
|
|
61
|
+
const src = resolve(projectDir, f);
|
|
62
|
+
const dst = join(stashRoot, f);
|
|
63
|
+
await mkdir(dirname(dst), { recursive: true });
|
|
64
|
+
await rename(src, dst);
|
|
65
|
+
untrackedMoves.push({ src, dst });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { trackedStashRef, untrackedMoves, marker, stashRoot };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Reverse a `stashFilesAside` token. Idempotent: a restored token becomes a no-op.
|
|
72
|
+
export async function restoreFiles(token, { projectDir = process.cwd(), runner = defaultRunner } = {}) {
|
|
73
|
+
if (!token || (token.trackedStashRef == null && token.untrackedMoves.length === 0)) return;
|
|
74
|
+
|
|
75
|
+
for (const { src, dst } of token.untrackedMoves) {
|
|
76
|
+
if (existsSync(dst)) {
|
|
77
|
+
await mkdir(dirname(src), { recursive: true });
|
|
78
|
+
await rename(dst, src);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (token.stashRoot && existsSync(token.stashRoot)) {
|
|
82
|
+
await rm(token.stashRoot, { recursive: true, force: true });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (token.trackedStashRef) {
|
|
86
|
+
const res = await runner("git", ["stash", "pop", token.trackedStashRef], { cwd: projectDir });
|
|
87
|
+
if (res.exitCode !== 0) {
|
|
88
|
+
throw new Error(`git stash pop failed: ${res.stderr || res.stdout}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
token.trackedStashRef = null;
|
|
92
|
+
token.untrackedMoves = [];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Run `body` with `files` stashed aside, always restoring them — even when
|
|
96
|
+
// `body` throws. The TDD discipline stage wraps its "red" run with this.
|
|
97
|
+
export async function withStashedFiles(files, body, opts = {}) {
|
|
98
|
+
const token = await stashFilesAside(files, opts);
|
|
99
|
+
try {
|
|
100
|
+
return await body(token);
|
|
101
|
+
} finally {
|
|
102
|
+
await restoreFiles(token, opts).catch(() => { /* swallow: surface body error */ });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// TDD discipline gate (KJC-TSK-0398 PR1).
|
|
2
|
+
// PR1 ships the pure decision logic with injected runTests/stashFiles
|
|
3
|
+
// helpers. PR2 ships the real surgical stash. PR3 wires the gate into
|
|
4
|
+
// the pipeline behind `development.require_red_then_green`.
|
|
5
|
+
|
|
6
|
+
const PARSERS = {
|
|
7
|
+
vitest: parseVitestFailures,
|
|
8
|
+
jest: parseVitestFailures,
|
|
9
|
+
pytest: parsePytestFailures,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function parseVitestFailures(output) {
|
|
13
|
+
const failures = new Set();
|
|
14
|
+
for (const line of String(output || "").split("\n")) {
|
|
15
|
+
const m = line.match(/^\s*(?:FAIL|×|✖)\s+(\S+)/);
|
|
16
|
+
if (m) failures.add(m[1].trim());
|
|
17
|
+
}
|
|
18
|
+
return failures;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parsePytestFailures(output) {
|
|
22
|
+
const failures = new Set();
|
|
23
|
+
for (const line of String(output || "").split("\n")) {
|
|
24
|
+
const m = line.match(/^FAILED\s+(\S+)/);
|
|
25
|
+
if (m) failures.add(m[1].trim());
|
|
26
|
+
}
|
|
27
|
+
return failures;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function anyNewTestIn(failures, testFiles) {
|
|
31
|
+
return [...failures].some((f) => testFiles.some((t) => f.includes(t) || t.includes(f)));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function verifyTddDiscipline({
|
|
35
|
+
projectDir,
|
|
36
|
+
sourceFiles = [],
|
|
37
|
+
testFiles = [],
|
|
38
|
+
framework = null,
|
|
39
|
+
runTests,
|
|
40
|
+
stashFiles,
|
|
41
|
+
restoreFiles,
|
|
42
|
+
} = {}) {
|
|
43
|
+
if (sourceFiles.length === 0) {
|
|
44
|
+
return { ok: true, reason: "no_source_changes", sourceFiles, testFiles };
|
|
45
|
+
}
|
|
46
|
+
if (testFiles.length === 0) {
|
|
47
|
+
return { ok: true, reason: "no_test_changes", sourceFiles, testFiles };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const parser = framework ? PARSERS[framework] : null;
|
|
51
|
+
if (!parser) {
|
|
52
|
+
return {
|
|
53
|
+
ok: true,
|
|
54
|
+
reason: "framework_unsupported",
|
|
55
|
+
sourceFiles,
|
|
56
|
+
testFiles,
|
|
57
|
+
warning: `tdd-discipline: framework "${framework || "unknown"}" has no failure parser; gate skipped`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let stashToken = null;
|
|
62
|
+
try {
|
|
63
|
+
stashToken = await stashFiles(sourceFiles);
|
|
64
|
+
const before = await runTests(projectDir);
|
|
65
|
+
if (!anyNewTestIn(parser(before.output), testFiles)) {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
reason: "tests_pass_without_impl",
|
|
69
|
+
sourceFiles,
|
|
70
|
+
testFiles,
|
|
71
|
+
message:
|
|
72
|
+
"TDD discipline violation: new tests pass without the implementation. Tests must fail first (red), then pass (green).",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
} finally {
|
|
76
|
+
if (stashToken) await restoreFiles(stashToken);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const after = await runTests(projectDir);
|
|
80
|
+
if (anyNewTestIn(parser(after.output), testFiles)) {
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
reason: "tests_fail_with_impl",
|
|
84
|
+
sourceFiles,
|
|
85
|
+
testFiles,
|
|
86
|
+
message: "TDD discipline violation: new tests still fail with the implementation present.",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return { ok: true, reason: "red_then_green_verified", sourceFiles, testFiles };
|
|
90
|
+
}
|
package/src/roles/audit-role.js
CHANGED
|
@@ -8,6 +8,7 @@ import { collectOsvFindings } from "../audit/osv-findings.js";
|
|
|
8
8
|
import { collectSemgrepFindings } from "../audit/semgrep-findings.js";
|
|
9
9
|
import { collectCircularDeps } from "../audit/circular-deps.js";
|
|
10
10
|
import { collectDeadExports } from "../audit/dead-exports.js";
|
|
11
|
+
import { collectInjectionFindings } from "../audit/injection-findings.js";
|
|
11
12
|
|
|
12
13
|
function parseDimensions(dimensionsStr) {
|
|
13
14
|
if (!dimensionsStr || dimensionsStr === "all") return null;
|
|
@@ -51,6 +52,7 @@ export class AuditRole extends AgentRole {
|
|
|
51
52
|
const noSemgrep = typeof input === "object" ? Boolean(input?.noSemgrep) : false;
|
|
52
53
|
const noMadge = typeof input === "object" ? Boolean(input?.noMadge) : false;
|
|
53
54
|
const noKnip = typeof input === "object" ? Boolean(input?.noKnip) : false;
|
|
55
|
+
const noInjectionScan = typeof input === "object" ? Boolean(input?.noInjectionScan) : false;
|
|
54
56
|
const projectDir = this.config?.projectDir || process.cwd();
|
|
55
57
|
let basalCost = null;
|
|
56
58
|
let growthDelta = null;
|
|
@@ -61,6 +63,7 @@ export class AuditRole extends AgentRole {
|
|
|
61
63
|
let semgrepFindings = null;
|
|
62
64
|
let circularDeps = null;
|
|
63
65
|
let deadExports = null;
|
|
66
|
+
let injectionFindings = null;
|
|
64
67
|
try {
|
|
65
68
|
basalCost = await measureBasalCost(projectDir);
|
|
66
69
|
const previous = await loadPreviousAudit(projectDir);
|
|
@@ -108,7 +111,12 @@ export class AuditRole extends AgentRole {
|
|
|
108
111
|
deadExports = await collectDeadExports(projectDir, stack, this.config, this.logger);
|
|
109
112
|
} catch { /* knip is best-effort */ }
|
|
110
113
|
}
|
|
111
|
-
|
|
114
|
+
if (!noInjectionScan) {
|
|
115
|
+
try {
|
|
116
|
+
injectionFindings = await collectInjectionFindings(projectDir, this.logger);
|
|
117
|
+
} catch { /* injection scan is best-effort (KJC-TSK-0498) */ }
|
|
118
|
+
}
|
|
119
|
+
return { projectDir, basalCost, growthDelta, stack, sonarFindings, webperf, osvFindings, semgrepFindings, circularDeps, deadExports, injectionFindings };
|
|
112
120
|
}
|
|
113
121
|
|
|
114
122
|
/**
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// KJC-TSK-0375 — ToolCorrectnessJudgeRole.
|
|
2
|
+
// Post-iteration LLM judge scoring how well a coder picked its tool calls.
|
|
3
|
+
// PR1 ships role + tests; wiring lives in PR3.
|
|
4
|
+
|
|
5
|
+
import { AgentRole } from "./agent-role.js";
|
|
6
|
+
import { buildToolCorrectnessJudgePrompt } from "../prompts/tool-correctness-judge.js";
|
|
7
|
+
import { extractFirstJson } from "../utils/json-extract.js";
|
|
8
|
+
|
|
9
|
+
const DEFAULT_THRESHOLD = 70;
|
|
10
|
+
|
|
11
|
+
const clampScore = (n) => Number.isFinite(n) ? Math.max(0, Math.min(100, Math.round(n))) : null;
|
|
12
|
+
|
|
13
|
+
const normalizeBreakdown = (b) => Array.isArray(b) ? b.map((x) => ({
|
|
14
|
+
tool: typeof x?.tool === "string" ? x.tool : "unknown",
|
|
15
|
+
args: x?.args && typeof x.args === "object" ? x.args : {},
|
|
16
|
+
correct: Boolean(x?.correct),
|
|
17
|
+
reason: typeof x?.reason === "string" ? x.reason.trim() : "",
|
|
18
|
+
})) : [];
|
|
19
|
+
|
|
20
|
+
export class ToolCorrectnessJudgeRole extends AgentRole {
|
|
21
|
+
constructor(opts) {
|
|
22
|
+
super({ ...opts, name: "tool-correctness-judge" });
|
|
23
|
+
this.threshold = opts?.threshold ?? this.config?.pipeline?.tool_judge?.threshold ?? DEFAULT_THRESHOLD;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
resolveProvider() {
|
|
27
|
+
return this.config?.roles?.["tool-correctness-judge"]?.provider
|
|
28
|
+
|| this.config?.roles?.reviewer?.provider
|
|
29
|
+
|| this.config?.roles?.coder?.provider
|
|
30
|
+
|| "claude";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
extractInput(input) {
|
|
34
|
+
return { ...super.extractInput(input), toolCalls: Array.isArray(input?.toolCalls) ? input.toolCalls : [] };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async buildPrompt({ task, toolCalls }) {
|
|
38
|
+
return { prompt: buildToolCorrectnessJudgePrompt({ task, toolCalls, instructions: this.instructions }) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
parseOutput(raw) { return extractFirstJson(raw); }
|
|
42
|
+
|
|
43
|
+
buildSuccessResult(parsed, provider) {
|
|
44
|
+
const score = clampScore(parsed?.score);
|
|
45
|
+
return {
|
|
46
|
+
score,
|
|
47
|
+
summary: typeof parsed?.summary === "string" ? parsed.summary.trim() : "",
|
|
48
|
+
breakdown: normalizeBreakdown(parsed?.breakdown),
|
|
49
|
+
threshold: this.threshold,
|
|
50
|
+
lowQuality: score !== null && score < this.threshold,
|
|
51
|
+
provider,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
buildSummary(parsed) {
|
|
56
|
+
const score = clampScore(parsed?.score);
|
|
57
|
+
if (score === null) return "Tool-judge: unparseable score";
|
|
58
|
+
return `Tool-judge: ${score}/100 (${score < this.threshold ? "LOW" : "OK"})`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Empty tool-call list = nothing to judge. Skip the LLM and report a
|
|
62
|
+
// neutral score so an absent transcript is never read as a failure.
|
|
63
|
+
async execute(input) {
|
|
64
|
+
const extracted = this.extractInput(input);
|
|
65
|
+
if (extracted.toolCalls.length === 0) {
|
|
66
|
+
return {
|
|
67
|
+
ok: true,
|
|
68
|
+
result: {
|
|
69
|
+
score: null, summary: "no tool calls captured (skipped)", breakdown: [],
|
|
70
|
+
threshold: this.threshold, lowQuality: false, provider: this.resolveProvider(), skipped: true,
|
|
71
|
+
},
|
|
72
|
+
summary: "Tool-judge: skipped (no calls)",
|
|
73
|
+
usage: null,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
return super.execute(extracted);
|
|
77
|
+
}
|
|
78
|
+
}
|
package/src/session/mutators.js
CHANGED
|
@@ -109,6 +109,10 @@ export function setSonarSummary(session, text) {
|
|
|
109
109
|
session.last_sonar_summary = text;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
export function setCoderTranscript(session, text) {
|
|
113
|
+
session.last_coder_transcript = text;
|
|
114
|
+
}
|
|
115
|
+
|
|
112
116
|
export function setSonarIssueSignature(session, sig) {
|
|
113
117
|
session.last_sonar_issue_signature = sig;
|
|
114
118
|
}
|
package/src/sonar/scanner.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import fsPromises from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
4
5
|
import { runCommand } from "../utils/process.js";
|
|
5
6
|
import { sonarUp } from "./manager.js";
|
|
6
7
|
import { resolveSonarProjectKey } from "./project-key.js";
|
|
@@ -10,6 +11,32 @@ import {
|
|
|
10
11
|
resolveSonarCredentials,
|
|
11
12
|
} from "./config-resolver.js";
|
|
12
13
|
|
|
14
|
+
export async function findNativeSonarScanner() {
|
|
15
|
+
const which = await runCommand("sh", ["-c", "command -v sonar-scanner"]);
|
|
16
|
+
if (which.exitCode === 0) {
|
|
17
|
+
const bin = String(which.stdout || "").trim();
|
|
18
|
+
if (bin) return bin;
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function pickSonarScanner(scannerConfig = {}, deps = {}) {
|
|
24
|
+
const find = deps.findNativeSonarScanner || findNativeSonarScanner;
|
|
25
|
+
const arch = deps.arch || os.arch();
|
|
26
|
+
const forced = String(scannerConfig.command || "").trim().toLowerCase();
|
|
27
|
+
if (forced === "docker") return { type: "docker" };
|
|
28
|
+
if (forced === "native" || forced === "sonar-scanner" || forced.endsWith("/sonar-scanner")) {
|
|
29
|
+
const bin = (forced === "native" || forced === "sonar-scanner") ? await find() : forced;
|
|
30
|
+
if (bin) return { type: "native", bin };
|
|
31
|
+
return { type: "docker", reason: "native sonar-scanner not found on PATH" };
|
|
32
|
+
}
|
|
33
|
+
if (arch === "arm64") {
|
|
34
|
+
const bin = await find();
|
|
35
|
+
if (bin) return { type: "native", bin, reason: "ARM64 host — preferring native binary" };
|
|
36
|
+
}
|
|
37
|
+
return { type: "docker" };
|
|
38
|
+
}
|
|
39
|
+
|
|
13
40
|
export function buildScannerOpts(projectKey, scanner = {}) {
|
|
14
41
|
const opts = [`-Dsonar.projectKey=${projectKey}`];
|
|
15
42
|
if (scanner.sources) opts.push(`-Dsonar.sources=${scanner.sources}`);
|
|
@@ -236,31 +263,39 @@ export async function runSonarScan(config, projectKey = null) {
|
|
|
236
263
|
...coverage.scannerPatch
|
|
237
264
|
});
|
|
238
265
|
|
|
239
|
-
const
|
|
240
|
-
"run",
|
|
241
|
-
"--rm",
|
|
242
|
-
"-v",
|
|
243
|
-
`${process.cwd()}:/usr/src`,
|
|
244
|
-
...(isLocalHost ? ["--add-host", "host.docker.internal:host-gateway"] : []),
|
|
245
|
-
...(isLocalHost || isExternalSonar ? [] : ["--network", sonarNetwork]),
|
|
246
|
-
"-e", "SONAR_HOST_URL",
|
|
247
|
-
"-e", "SONAR_TOKEN",
|
|
248
|
-
"-e", "SONAR_SCANNER_OPTS",
|
|
249
|
-
"sonarsource/sonar-scanner-cli"
|
|
250
|
-
];
|
|
251
|
-
|
|
252
|
-
// Pass secrets via process env, not CLI args — invisible in `ps aux`
|
|
266
|
+
const pick = await pickSonarScanner(sonarConfig.scanner);
|
|
253
267
|
const env = {
|
|
254
268
|
...process.env,
|
|
255
|
-
SONAR_HOST_URL: host,
|
|
269
|
+
SONAR_HOST_URL: pick.type === "native" ? rawHost : host,
|
|
256
270
|
SONAR_TOKEN: token || "",
|
|
257
271
|
SONAR_SCANNER_OPTS: buildScannerOpts(effectiveProjectKey, scannerConfig)
|
|
258
272
|
};
|
|
259
273
|
|
|
260
|
-
|
|
274
|
+
let cmd, args;
|
|
275
|
+
if (pick.type === "native") {
|
|
276
|
+
cmd = pick.bin;
|
|
277
|
+
args = [];
|
|
278
|
+
} else {
|
|
279
|
+
cmd = "docker";
|
|
280
|
+
args = [
|
|
281
|
+
"run",
|
|
282
|
+
"--rm",
|
|
283
|
+
"-v",
|
|
284
|
+
`${process.cwd()}:/usr/src`,
|
|
285
|
+
...(isLocalHost ? ["--add-host", "host.docker.internal:host-gateway"] : []),
|
|
286
|
+
...(isLocalHost || isExternalSonar ? [] : ["--network", sonarNetwork]),
|
|
287
|
+
"-e", "SONAR_HOST_URL",
|
|
288
|
+
"-e", "SONAR_TOKEN",
|
|
289
|
+
"-e", "SONAR_SCANNER_OPTS",
|
|
290
|
+
"sonarsource/sonar-scanner-cli"
|
|
291
|
+
];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const result = await runCommand(cmd, args, { timeout: scannerTimeout, env });
|
|
261
295
|
return {
|
|
262
296
|
ok: result.exitCode === 0,
|
|
263
297
|
projectKey: effectiveProjectKey,
|
|
298
|
+
scanner: pick.type,
|
|
264
299
|
stdout: result.stdout,
|
|
265
300
|
stderr: result.stderr,
|
|
266
301
|
exitCode: result.exitCode
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kj sync --apply — append a drift patch to the Canvas markdown.
|
|
3
|
+
*
|
|
4
|
+
* KJC-TSK-0348. Pure data + I/O: reads the Canvas at `canvasPath`, drops a
|
|
5
|
+
* `.bak` snapshot alongside it, appends an HTML-comment block describing
|
|
6
|
+
* untracked / missing / covered files, and atomically renames a `.tmp`
|
|
7
|
+
* over the target. Comments are inert for the Canvas parser, so the
|
|
8
|
+
* patched file stays structurally valid — the user converts the TODOs
|
|
9
|
+
* into Operations by hand.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { promises as fs } from "node:fs";
|
|
13
|
+
|
|
14
|
+
function tempPath(target) {
|
|
15
|
+
return `${target}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function renderDriftComment(report) {
|
|
19
|
+
const lines = ["", `<!-- kj sync --apply (${new Date().toISOString()}) — review and convert into Operations as needed. -->`];
|
|
20
|
+
let mutations = 0;
|
|
21
|
+
if (report.untracked?.length) {
|
|
22
|
+
lines.push("<!-- Untracked files (modified, no Operation references them — add one): -->");
|
|
23
|
+
for (const u of report.untracked) { lines.push(`<!-- + ${u.path} -->`); mutations += 1; }
|
|
24
|
+
}
|
|
25
|
+
if (report.missing?.length) {
|
|
26
|
+
lines.push("<!-- Missing files (Operation file_hint points at a file that does not exist): -->");
|
|
27
|
+
for (const m of report.missing) { lines.push(`<!-- ! ${m.path} [op #${(m.operationIndex ?? 0) + 1}] -->`); mutations += 1; }
|
|
28
|
+
}
|
|
29
|
+
if (report.covered?.length) {
|
|
30
|
+
lines.push("<!-- Covered (referenced and modified — consider extending the Approach): -->");
|
|
31
|
+
for (const c of report.covered) lines.push(`<!-- ~ ${c.path} [op #${(c.operationIndex ?? 0) + 1}] -->`);
|
|
32
|
+
}
|
|
33
|
+
lines.push("<!-- /kj sync -->", "");
|
|
34
|
+
return { block: lines.join("\n"), mutations };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function applyCanvasPatch({ canvasPath, report }) {
|
|
38
|
+
if (!canvasPath || typeof canvasPath !== "string") throw new Error("applyCanvasPatch: canvasPath is required");
|
|
39
|
+
if (!report || typeof report !== "object") throw new Error("applyCanvasPatch: report is required");
|
|
40
|
+
const hasDrift = (report.untracked?.length || 0) + (report.missing?.length || 0) > 0;
|
|
41
|
+
if (!hasDrift) return { patchedPath: canvasPath, backupPath: null, mutations: 0, skipped: true };
|
|
42
|
+
|
|
43
|
+
let original;
|
|
44
|
+
try { original = await fs.readFile(canvasPath, "utf-8"); }
|
|
45
|
+
catch (err) { throw new Error(`applyCanvasPatch: cannot read canvas ${canvasPath}: ${err.message}`, { cause: err }); }
|
|
46
|
+
|
|
47
|
+
const backupPath = `${canvasPath}.bak`;
|
|
48
|
+
await fs.writeFile(backupPath, original, "utf-8");
|
|
49
|
+
const { block, mutations } = renderDriftComment(report);
|
|
50
|
+
const trimmed = original.endsWith("\n") ? original : `${original}\n`;
|
|
51
|
+
const tmp = tempPath(canvasPath);
|
|
52
|
+
await fs.writeFile(tmp, `${trimmed}${block}\n`, "utf-8");
|
|
53
|
+
await fs.rename(tmp, canvasPath);
|
|
54
|
+
return { patchedPath: canvasPath, backupPath, mutations };
|
|
55
|
+
}
|
package/src/utils/paths.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
1
2
|
import os from "node:os";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
|
|
@@ -12,6 +13,17 @@ function vitestRoot() {
|
|
|
12
13
|
os.tmpdir(),
|
|
13
14
|
`karajan-vitest-${process.pid}-${Math.random().toString(36).slice(2, 10)}`
|
|
14
15
|
);
|
|
16
|
+
// KJC-BUG-0075: clean up on clean exit so /tmp doesn't accumulate one
|
|
17
|
+
// dir per fork × run. `process.on('exit')` runs synchronously and only
|
|
18
|
+
// for graceful exits — SIGKILL leaks survive but are caught by the
|
|
19
|
+
// global-setup mtime purge at the start of the next run.
|
|
20
|
+
process.on("exit", () => {
|
|
21
|
+
try {
|
|
22
|
+
fs.rmSync(_vitestRoot, { recursive: true, force: true });
|
|
23
|
+
} catch {
|
|
24
|
+
// best-effort: never crash a test process on cleanup failure
|
|
25
|
+
}
|
|
26
|
+
});
|
|
15
27
|
return _vitestRoot;
|
|
16
28
|
}
|
|
17
29
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// KJ_SIMULATE_QUOTA — testing hook for the standby flow. See
|
|
2
|
+
// docs/quota-simulation.md. KJC-TSK-0493.
|
|
3
|
+
|
|
4
|
+
let _warned = false;
|
|
5
|
+
|
|
6
|
+
export function parseQuotaSimulationConfig(env = process.env) {
|
|
7
|
+
const trigger = env.KJ_SIMULATE_QUOTA;
|
|
8
|
+
if (!trigger) return null;
|
|
9
|
+
const m = /^after-iter-(\d+)$/i.exec(String(trigger).trim());
|
|
10
|
+
if (!m) {
|
|
11
|
+
if (!_warned) {
|
|
12
|
+
// eslint-disable-next-line no-console
|
|
13
|
+
console.warn(`[KJ_SIMULATE_QUOTA] Ignoring malformed value "${trigger}". Expected "after-iter-<N>".`);
|
|
14
|
+
_warned = true;
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
afterIter: Number.parseInt(m[1], 10),
|
|
20
|
+
resetIso: env.KJ_SIMULATE_QUOTA_RESET || new Date(Date.now() + 3600_000).toISOString(),
|
|
21
|
+
agent: String(env.KJ_SIMULATE_QUOTA_AGENT || "claude").toLowerCase(),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const SYNTHETIC_MSG = {
|
|
26
|
+
claude: (iso) => `[KJ_SIMULATE_QUOTA] Claude Pro usage limit reached. weekly limit. resets at ${iso}`,
|
|
27
|
+
codex: () => `[KJ_SIMULATE_QUOTA] You have exceeded your current quota. retry after 60 seconds`,
|
|
28
|
+
gemini: () => `[KJ_SIMULATE_QUOTA] Resource exhausted: quota exceeded. retry after 60 seconds`,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function buildSyntheticQuotaMessage(agent, resetIso) {
|
|
32
|
+
return (SYNTHETIC_MSG[agent] || SYNTHETIC_MSG.claude)(resetIso);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function shouldSimulateQuota({ iteration, agent }, env = process.env) {
|
|
36
|
+
const cfg = parseQuotaSimulationConfig(env);
|
|
37
|
+
if (!cfg) return null;
|
|
38
|
+
if (Number(iteration) < cfg.afterIter) return null;
|
|
39
|
+
const a = String(agent || "").toLowerCase();
|
|
40
|
+
if (cfg.agent !== "any" && cfg.agent !== a) return null;
|
|
41
|
+
return { resetIso: cfg.resetIso, agent: a || cfg.agent };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Mutates execResult: forces ok=false and prepends the synthetic quota
|
|
45
|
+
// line to result.error so the existing detectRateLimit branch fires.
|
|
46
|
+
export function applyQuotaSimulation(execResult, { iteration, agent }, env = process.env) {
|
|
47
|
+
const fire = shouldSimulateQuota({ iteration, agent }, env);
|
|
48
|
+
if (!fire || !execResult || typeof execResult !== "object") return false;
|
|
49
|
+
if (!execResult.result || typeof execResult.result !== "object") execResult.result = {};
|
|
50
|
+
execResult.result.error = `${buildSyntheticQuotaMessage(fire.agent, fire.resetIso)}\n${execResult.result.error || ""}`;
|
|
51
|
+
execResult.ok = false;
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Test seam — reset the one-shot warning between unit tests.
|
|
56
|
+
export function _resetSimulatorWarning() { _warned = false; }
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repo-level cleaner — KJC-TSK-0499 PR1. Read-only collector that
|
|
3
|
+
* surfaces stale local artifacts so the user can decide what to drop.
|
|
4
|
+
* Detects: branches merged into a base ref, branches with `[gone]`
|
|
5
|
+
* upstream, top-level `dist/` `coverage/` `logs/` older than N days,
|
|
6
|
+
* `*.tmp` / `*.bak` older than N days. Never spawns destructive ops.
|
|
7
|
+
*/
|
|
8
|
+
import fs from "node:fs/promises";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { execa } from "execa";
|
|
11
|
+
|
|
12
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
13
|
+
const STALE_DIRS = ["dist", "coverage", "logs"];
|
|
14
|
+
const STALE_FILE_RX = /\.(tmp|bak)$/;
|
|
15
|
+
|
|
16
|
+
async function safeGit(cwd, args) {
|
|
17
|
+
try {
|
|
18
|
+
const { stdout, exitCode } = await execa("git", args, { cwd, reject: false });
|
|
19
|
+
return exitCode === 0 ? stdout : "";
|
|
20
|
+
} catch { return ""; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function collectBranches(cwd, baseRef) {
|
|
24
|
+
const items = [];
|
|
25
|
+
const baseShort = baseRef.replace(/^origin\//, "");
|
|
26
|
+
const current = (await safeGit(cwd, ["symbolic-ref", "--short", "HEAD"])).trim();
|
|
27
|
+
const merged = await safeGit(cwd, ["branch", "--merged", baseRef, "--format=%(refname:short)"]);
|
|
28
|
+
for (const raw of merged.split("\n")) {
|
|
29
|
+
const name = raw.trim();
|
|
30
|
+
if (!name || name === current || name === baseShort) continue;
|
|
31
|
+
items.push({ kind: "branch", target: name, reason: `merged into ${baseRef}`, command: `git branch -d ${name}` });
|
|
32
|
+
}
|
|
33
|
+
for (const line of (await safeGit(cwd, ["branch", "-vv"])).split("\n")) {
|
|
34
|
+
const m = line.match(/^[\s*]+(\S+)\s+\S+\s+\[[^:]+: gone\]/);
|
|
35
|
+
if (m && m[1] !== current) items.push({ kind: "branch-gone", target: m[1], reason: "upstream gone", command: `git branch -D ${m[1]}` });
|
|
36
|
+
}
|
|
37
|
+
return items;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function ageMs(p) {
|
|
41
|
+
try { return Date.now() - (await fs.stat(p)).mtimeMs; } catch { return -1; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function collectStale(cwd, maxAgeDays) {
|
|
45
|
+
const cutoff = maxAgeDays * DAY_MS;
|
|
46
|
+
const items = [];
|
|
47
|
+
for (const name of STALE_DIRS) {
|
|
48
|
+
const full = path.join(cwd, name);
|
|
49
|
+
if (await ageMs(full) > cutoff) {
|
|
50
|
+
items.push({ kind: "dir", target: full, reason: `${name}/ older than ${maxAgeDays}d`, command: `rm -rf ${full}` });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
let entries = [];
|
|
54
|
+
try { entries = await fs.readdir(cwd, { withFileTypes: true }); } catch { /* cwd unreadable */ }
|
|
55
|
+
for (const e of entries) {
|
|
56
|
+
if (!e.isFile() || !STALE_FILE_RX.test(e.name)) continue;
|
|
57
|
+
const full = path.join(cwd, e.name);
|
|
58
|
+
if (await ageMs(full) > cutoff) {
|
|
59
|
+
items.push({ kind: "file", target: full, reason: `${path.extname(e.name)} older than ${maxAgeDays}d`, command: `rm -f ${full}` });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return items;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Discover repo-level cleanup candidates.
|
|
67
|
+
* @param {object} opts
|
|
68
|
+
* @param {string} [opts.cwd=process.cwd()]
|
|
69
|
+
* @param {number} [opts.maxAgeDays=7]
|
|
70
|
+
* @param {string} [opts.mergedTo="origin/main"]
|
|
71
|
+
* @returns {Promise<{candidates: Array, errors: Array}>}
|
|
72
|
+
*/
|
|
73
|
+
export async function collectRepoCandidates({ cwd = process.cwd(), maxAgeDays = 7, mergedTo = "origin/main" } = {}) {
|
|
74
|
+
const candidates = [];
|
|
75
|
+
const errors = [];
|
|
76
|
+
try { candidates.push(...await collectBranches(cwd, mergedTo)); } catch (e) { errors.push({ stage: "branches", error: String(e?.message ?? e) }); }
|
|
77
|
+
try { candidates.push(...await collectStale(cwd, maxAgeDays)); } catch (e) { errors.push({ stage: "stale", error: String(e?.message ?? e) }); }
|
|
78
|
+
return { candidates, errors };
|
|
79
|
+
}
|