karajan-code 4.36.0 → 4.37.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/package.json +1 -1
- package/src/cli/advanced-commands.js +1 -1
- package/src/cli/register-meta.js +11 -0
- package/src/commands/pr-size.js +45 -0
- package/src/commands/review-gate.js +33 -1
- package/src/harden/hook-templates.js +7 -0
- package/src/harden/phone-sign.js +28 -11
- package/src/harden/sentinel-hooks.js +14 -0
- package/src/rag/indexable.js +32 -1
- package/src/review/comment-only.js +93 -0
- package/src/review/tests-with-code.js +5 -2
package/package.json
CHANGED
|
@@ -34,7 +34,7 @@ export const ADVANCED_GROUPS = [
|
|
|
34
34
|
{ title: "Pipeline (piezas sueltas)", commands: ["autorun", "code", "review", "solomon", "agent", "scan", "tournament"] },
|
|
35
35
|
{ title: "Análisis pre-run", commands: ["discover", "triage", "researcher", "architect", "onboard", "brief"] },
|
|
36
36
|
{ title: "Búsqueda / RAG", commands: ["rag", "qmd", "watch"] },
|
|
37
|
-
{ title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar", "privacy", "release", "policy", "claims", "steward"] },
|
|
37
|
+
{ title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar", "privacy", "release", "policy", "claims", "steward", "pr-size"] },
|
|
38
38
|
{ title: "Sesión / board", commands: ["resume", "report", "board", "hu", "adr", "worktree", "undo", "standby", "sentinel", "identity"] },
|
|
39
39
|
{ title: "Infra / setup", commands: ["install-tools", "ollama", "skills", "roles", "agents", "env"] },
|
|
40
40
|
{ title: "Mantenimiento", commands: ["clean", "sync", "telemetry", "report-issue"] },
|
package/src/cli/register-meta.js
CHANGED
|
@@ -522,6 +522,17 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
522
522
|
await privacyScanCommand({ paths, flags });
|
|
523
523
|
});
|
|
524
524
|
|
|
525
|
+
// KJC-TSK-0910: the branch's size while it is written, same budget as CI.
|
|
526
|
+
program.command("pr-size")
|
|
527
|
+
.description("Lines this branch adds against its base, with the CI budget (committed, uncommitted and new files)")
|
|
528
|
+
.option("--json", "Machine-readable result")
|
|
529
|
+
.action(async (flags) => {
|
|
530
|
+
await withConfig(pkgVersion, "pr-size", flags, async ({ config, logger }) => {
|
|
531
|
+
const { prSizeCommand } = await import("../commands/pr-size.js");
|
|
532
|
+
await prSizeCommand({ config, logger, flags });
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
|
|
525
536
|
const rag = program.command("rag").description("Retrieval-augmented search over Karajan plans, onboarding briefs and project code");
|
|
526
537
|
rag.command("index")
|
|
527
538
|
.description("Index plans + onboarding (and optionally project sources) into the local vector store")
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-0910 — `kj pr-size`: the branch's size while it is being written,
|
|
3
|
+
* with the SAME budget the CI gate applies (loc-budget.js). It counts what is
|
|
4
|
+
* committed, what is not yet, and new files not yet added, against the merge
|
|
5
|
+
* base with the base branch, so the Sentinel can warn before the gate blocks.
|
|
6
|
+
*/
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
import { budgetedAdded } from "../review/loc-budget.js";
|
|
12
|
+
|
|
13
|
+
const git = (projectDir, args) =>
|
|
14
|
+
execFileSync("git", ["-C", projectDir, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
15
|
+
|
|
16
|
+
function mergeBase(projectDir, base) {
|
|
17
|
+
for (const ref of [`origin/${base}`, base]) {
|
|
18
|
+
try { return git(projectDir, ["merge-base", "HEAD", ref]).trim(); } catch { /* try the next ref */ }
|
|
19
|
+
}
|
|
20
|
+
throw new Error(`pr-size: no merge base with ${base} (nor origin/${base})`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** @returns {Promise<{added: number, exempt: number, testAdded: number, base: string}>} */
|
|
24
|
+
export async function branchSize({ projectDir = process.cwd(), base = "main" } = {}) {
|
|
25
|
+
const from = mergeBase(projectDir, base);
|
|
26
|
+
// Working tree against the merge base: committed and uncommitted together.
|
|
27
|
+
let numstat = git(projectDir, ["diff", "--numstat", from]);
|
|
28
|
+
// New files not yet added are where a branch grows while it is written.
|
|
29
|
+
for (const file of git(projectDir, ["ls-files", "--others", "--exclude-standard"]).split("\n").filter(Boolean)) {
|
|
30
|
+
let text;
|
|
31
|
+
try { text = readFileSync(join(projectDir, file), "utf8"); } catch { continue; }
|
|
32
|
+
if (text.includes("\0")) continue; // binary
|
|
33
|
+
// Lines as git counts them: an empty file adds none.
|
|
34
|
+
const n = text === "" ? 0 : text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
|
|
35
|
+
numstat += `${n}\t0\t${file}\n`;
|
|
36
|
+
}
|
|
37
|
+
return { ...budgetedAdded(numstat), base };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function prSizeCommand({ config, logger = console, flags = {} }) {
|
|
41
|
+
const s = await branchSize({ projectDir: config?.projectDir || process.cwd(), base: config?.base_branch || "main" });
|
|
42
|
+
if (flags.json) process.stdout.write(`${JSON.stringify(s)}\n`);
|
|
43
|
+
else logger.info(`pr-size: ${s.added} line(s) added against ${s.base} (${s.testAdded} in tests, ${s.exempt} exempt)`);
|
|
44
|
+
return s;
|
|
45
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* verdict tied to the exact diff (verdict-store), so the pre-commit hook
|
|
6
6
|
* (ENV-C) can verify it. Exit code 0 = approved, 1 = rejected/stale.
|
|
7
7
|
*/
|
|
8
|
-
import { existsSync } from "node:fs";
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
9
|
import { isAbsolute, join } from "node:path";
|
|
10
10
|
import { runCommand } from "../utils/process.js";
|
|
11
11
|
import { checkVerdict, diffHash, pruneVerdicts } from "../review/verdict-store.js";
|
|
@@ -13,6 +13,7 @@ import { runOneShotReview } from "../review/one-shot-review.js";
|
|
|
13
13
|
import { runSolomonArbitration } from "../review/solomon-arbitration.js";
|
|
14
14
|
import { ensureGateTrackable } from "../review/gate-gitignore.js";
|
|
15
15
|
import { runSonarPregate, formatSonarFinding, addedLinesByFile } from "../review/sonar-pregate.js";
|
|
16
|
+
import { addedAreCommentsOnly } from "../review/comment-only.js";
|
|
16
17
|
import { checkSonarRequirement, SONAR_RULE_ID } from "../review/sonar-requirement.js";
|
|
17
18
|
import { checkRagRequirement, checkRagVerdict, ragBlock, RAG_RULE_ID } from "../review/rag-requirement.js";
|
|
18
19
|
import { checkUiEvidence, uiBlock } from "../review/ui-evidence.js";
|
|
@@ -59,6 +60,36 @@ async function rawDiff(range, extraArgs = []) {
|
|
|
59
60
|
return res.stdout;
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
/** The file as the reviewed diff leaves it. */
|
|
64
|
+
async function resultingContent(range, file, projectDir) {
|
|
65
|
+
const show = async (spec) => {
|
|
66
|
+
const res = await runCommand("git", ["show", spec]);
|
|
67
|
+
return res.exitCode === 0 ? res.stdout : null;
|
|
68
|
+
};
|
|
69
|
+
// Staged review: the INDEX, never the working tree (unstaged edits are not reviewed).
|
|
70
|
+
if (!range) return show(`:${file}`);
|
|
71
|
+
// `a..b` / `a...b`: the right side (HEAD when omitted).
|
|
72
|
+
if (/\.\./.test(range)) return show(`${range.split(/\.\.\.?/)[1] || "HEAD"}:${file}`);
|
|
73
|
+
// `git diff <ref>` compares the ref with the working tree, so that is the result.
|
|
74
|
+
try { return readFileSync(join(projectDir, file), "utf8"); } catch { return null; }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* KJC-BUG-0235: mark the files whose added lines are all comments, so a
|
|
79
|
+
* cleanup that corrects the comments it left lying is not taken for new code.
|
|
80
|
+
* Anything unreadable or of unknown syntax stays unmarked (counts as code).
|
|
81
|
+
*/
|
|
82
|
+
export async function markCommentOnlyAdditions(numstat, { range, projectDir }) {
|
|
83
|
+
const touched = numstat.filter((n) => n.added > 0);
|
|
84
|
+
if (touched.length === 0) return;
|
|
85
|
+
const added = addedLinesByFile(await rawDiff(range, ["--unified=0"]));
|
|
86
|
+
for (const n of touched) {
|
|
87
|
+
const lines = added.get(n.file);
|
|
88
|
+
const content = lines ? await resultingContent(range, n.file, projectDir) : null;
|
|
89
|
+
n.commentOnly = content !== null && addedAreCommentsOnly(content, lines, n.file);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
62
93
|
// KJC-TSK-0838: a grant is the ONLY way past the sonar requirement, so it is
|
|
63
94
|
// said with who gave it, its scope and until when.
|
|
64
95
|
const formatSonarGrant = (g) => {
|
|
@@ -286,6 +317,7 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
286
317
|
// exempt — deleting code adds no behavior to test.
|
|
287
318
|
const numstat = (await rawDiff(flags.range, ["--numstat"])).split("\n").map((l) => l.trim()).filter(Boolean)
|
|
288
319
|
.map((l) => { const [a, r, ...f] = l.split(/\s+/); return { file: f.join(" "), added: a === "-" ? 1 : Number(a) || 0, removed: r === "-" ? 0 : Number(r) || 0 }; });
|
|
320
|
+
await markCommentOnlyAdditions(numstat, { range: flags.range, projectDir });
|
|
289
321
|
const tests = checkTestsWithCode({ config, stagedFiles: changedFiles, numstat });
|
|
290
322
|
if (tests.mode === "delete-only") console.log(`⚠ tests-with-code: exempt — ${tests.reason}`);
|
|
291
323
|
if (tests.mode === "warn") console.log(`⚠ tests-with-code: ${tests.reason}`);
|
|
@@ -156,6 +156,13 @@ export function hookBody(hook, cmds = {}, { globalHooksDir = null, baseBranch =
|
|
|
156
156
|
" echo 'kj harden: commit header must follow Conventional Commits (type: subject)'; exit 1",
|
|
157
157
|
"fi",
|
|
158
158
|
'if [ "${#header}" -gt 100 ]; then echo \'kj harden: commit header exceeds 100 chars\'; exit 1; fi',
|
|
159
|
+
"# KJC-TSK-0909: the rest of what CI's commitlint rejects, caught here.",
|
|
160
|
+
"if ! awk 'NR > 2 && !/^#/ && length($0) > 100 { exit 1 }' \"$msg_file\"; then",
|
|
161
|
+
" echo 'kj harden: a commit body line exceeds 100 chars (CI commitlint rejects it)'; exit 1",
|
|
162
|
+
"fi",
|
|
163
|
+
'if [ -x node_modules/.bin/commitlint ]; then',
|
|
164
|
+
' node_modules/.bin/commitlint --edit "$msg_file" || exit 1',
|
|
165
|
+
"fi",
|
|
159
166
|
`if grep -qiE '${AI_ATTRIBUTION}' "$msg_file"; then`,
|
|
160
167
|
" echo 'kj harden: AI attribution is not allowed in commit messages'; exit 1",
|
|
161
168
|
"fi",
|
package/src/harden/phone-sign.js
CHANGED
|
@@ -17,18 +17,22 @@ const SPKI_ED25519_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
|
|
17
17
|
// escáner de privacidad del pack deniega claves google con razón general, y
|
|
18
18
|
// servirla desde la landing la hace además rotable sin release.
|
|
19
19
|
const RELAY_CONFIG_URL = "https://karajancode.com/sign/relay.json";
|
|
20
|
-
|
|
20
|
+
// Cacheada por fetch: en produccion es siempre la misma; en tests, cada relé falso es otro.
|
|
21
|
+
const relayCache = new WeakMap();
|
|
21
22
|
async function relayConfig(fetchFn) {
|
|
22
|
-
if (relayCache) return relayCache;
|
|
23
|
+
if (relayCache.has(fetchFn)) return relayCache.get(fetchFn);
|
|
23
24
|
const res = await fetchFn(RELAY_CONFIG_URL);
|
|
24
25
|
if (!res.ok) throw new Error(`phone-sign: no pude cargar la config del relé (${res.status}) — revisa la red`);
|
|
25
26
|
const cfg = await res.json();
|
|
26
27
|
if (!cfg?.apiKey || !cfg?.projectId || !cfg?.collection) throw new Error("phone-sign: config del relé incompleta");
|
|
27
|
-
|
|
28
|
+
const relay = {
|
|
28
29
|
apiKey: cfg.apiKey,
|
|
29
30
|
url: `https://firestore.googleapis.com/v1/projects/${cfg.projectId}/databases/(default)/documents/${cfg.collection}`,
|
|
31
|
+
// KJC-TSK-0901: v2 solo cuando la landing (pagina + reglas) ya lo entiende.
|
|
32
|
+
payloadVersion: cfg.payloadVersion === 2 ? 2 : 1,
|
|
30
33
|
};
|
|
31
|
-
|
|
34
|
+
relayCache.set(fetchFn, relay);
|
|
35
|
+
return relay;
|
|
32
36
|
}
|
|
33
37
|
const SIGN_PAGE = "https://karajancode.com/sign";
|
|
34
38
|
const POLL_INTERVAL_MS = 2000;
|
|
@@ -109,9 +113,15 @@ export function enrollPhone(publicKeyBase64, { home } = {}) {
|
|
|
109
113
|
writeFileSync(phoneKeyPath(home), `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
110
114
|
}
|
|
111
115
|
|
|
112
|
-
/**
|
|
113
|
-
|
|
116
|
+
/**
|
|
117
|
+
* Payload canónico firmado: files EXACTAMENTE como se enviaron, JSON sin espacios.
|
|
118
|
+
* KJC-TSK-0901/0902: v2 firma también lo demás que el móvil enseña (versión de
|
|
119
|
+
* kj) y la propia vigencia (emisión y caducidad, en ms), para que nada mostrado
|
|
120
|
+
* quede sin firmar y la caducidad sea de lo que el humano aprobó.
|
|
121
|
+
*/
|
|
122
|
+
export function canonicalPayload({ cid, nonce, project, files, v = 1, kjVersion, issuedMs, expiresMs }) {
|
|
114
123
|
const filesHash = createHash("sha256").update(JSON.stringify(files)).digest("hex");
|
|
124
|
+
if (v === 2) return `kj-supervisor-sign:v2:${cid}:${nonce}:${project}:${kjVersion}:${issuedMs}:${expiresMs}:${filesHash}`;
|
|
115
125
|
return `kj-supervisor-sign:v1:${cid}:${nonce}:${project}:${filesHash}`;
|
|
116
126
|
}
|
|
117
127
|
|
|
@@ -135,17 +145,21 @@ export async function requestPhoneSignature({ project, files, kjVersion, logger
|
|
|
135
145
|
const drawQr = deps.qr ?? ((url) => qrcode.generate(url, { small: true }));
|
|
136
146
|
const cid = randomBytes(16).toString("hex");
|
|
137
147
|
const nonce = randomBytes(16).toString("hex");
|
|
148
|
+
const relay = await relayConfig(fetchFn);
|
|
149
|
+
const v = relay.payloadVersion;
|
|
150
|
+
const issuedMs = now();
|
|
151
|
+
const expiresMs = issuedMs + TTL_MS;
|
|
138
152
|
const body = {
|
|
139
153
|
fields: {
|
|
140
154
|
nonce: { stringValue: nonce },
|
|
141
155
|
project: { stringValue: project },
|
|
142
156
|
kj_version: { stringValue: kjVersion },
|
|
143
157
|
state: { stringValue: "pending" },
|
|
144
|
-
createdAt: { timestampValue: new Date().toISOString() },
|
|
158
|
+
createdAt: { timestampValue: new Date(issuedMs).toISOString() },
|
|
145
159
|
files: { arrayValue: { values: files.map((f) => ({ mapValue: { fields: { file: { stringValue: f.file }, sha256: { stringValue: f.sha256 ?? "" } } } })) } },
|
|
160
|
+
...(v === 2 ? { v: { integerValue: "2" }, expires_at: { timestampValue: new Date(expiresMs).toISOString() } } : {}),
|
|
146
161
|
},
|
|
147
162
|
};
|
|
148
|
-
const relay = await relayConfig(fetchFn);
|
|
149
163
|
const created = await fetchFn(`${relay.url}?documentId=${cid}&key=${relay.apiKey}`, {
|
|
150
164
|
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body),
|
|
151
165
|
});
|
|
@@ -157,7 +171,7 @@ export async function requestPhoneSignature({ project, files, kjVersion, logger
|
|
|
157
171
|
logger.info?.(`phone-sign: escanea el QR o abre ${signUrl} y firma en el móvil (caduca en ${TTL_MS / 1000}s)`);
|
|
158
172
|
logger.info?.("phone-sign: esperando la firma del móvil…");
|
|
159
173
|
const spinner = deps.spinner ?? makeSpinner();
|
|
160
|
-
const deadline =
|
|
174
|
+
const deadline = expiresMs;
|
|
161
175
|
while (now() < deadline) {
|
|
162
176
|
const res = await fetchFn(`${relay.url}/${cid}?key=${relay.apiKey}`);
|
|
163
177
|
if (!res.ok) { spinner.stop(); throw new Error(`phone-sign: fallo consultando la petición de firma (HTTP ${res.status})`); }
|
|
@@ -171,14 +185,17 @@ export async function requestPhoneSignature({ project, files, kjVersion, logger
|
|
|
171
185
|
if (!signerKey || !authorized.has(signerKey)) {
|
|
172
186
|
return { ok: false, reason: "la publicKey que firma no está en el padrón autorizado — el doc es transporte, la verdad es el padrón" };
|
|
173
187
|
}
|
|
174
|
-
const
|
|
188
|
+
const signed2 = v === 2 ? { v, kjVersion, issuedMs, expiresMs } : {};
|
|
189
|
+
const payload = canonicalPayload({ cid, nonce, project, files, ...signed2 });
|
|
175
190
|
const signature = fields.signature?.stringValue ?? "";
|
|
176
191
|
const good = verifyPhoneSignature({ payload, signature, publicKey: signerKey });
|
|
177
192
|
if (!good) return { ok: false, reason: "firma ed25519 inválida para el payload canónico" };
|
|
193
|
+
// KJC-TSK-0901: una firma válida fuera de la vigencia que ella misma firma no vale.
|
|
194
|
+
if (v === 2 && now() > expiresMs) return { ok: false, reason: "la firma llegó fuera de su vigencia firmada" };
|
|
178
195
|
// KJC-TSK-0823: devuelve el desafío + firma para que el sello los GRABE en
|
|
179
196
|
// la provenance y CI pueda re-verificar server-side (clave del padrón).
|
|
180
197
|
const filesHash = createHash("sha256").update(JSON.stringify(files)).digest("hex");
|
|
181
|
-
return { ok: true, signer: signerKey, signature, challenge: { cid, nonce, project, filesHash } };
|
|
198
|
+
return { ok: true, signer: signerKey, signature, challenge: { cid, nonce, project, filesHash, ...signed2 } };
|
|
182
199
|
}
|
|
183
200
|
spinner.tick();
|
|
184
201
|
await sleep(POLL_INTERVAL_MS);
|
|
@@ -288,9 +288,23 @@ process.stdin.on("end", () => {
|
|
|
288
288
|
s.escapes.push(e);
|
|
289
289
|
(state.escape_events ||= []).push({ escape: e, tool, sid, ts: Date.now() });
|
|
290
290
|
}
|
|
291
|
+
// KJC-TSK-0910: the branch size while it is written, with the CI budget
|
|
292
|
+
// (kj pr-size), said once per threshold crossed. Context, never a block.
|
|
293
|
+
let sizeNote = null;
|
|
294
|
+
if (bucket) {
|
|
295
|
+
const ps = spawnSync("kj", ["pr-size", "--json"], { cwd: ROOT, encoding: "utf8", timeout: 5000 });
|
|
296
|
+
let size = null;
|
|
297
|
+
try { size = JSON.parse(String(ps.stdout || "").trim().split(String.fromCharCode(10)).pop()); } catch { size = null; }
|
|
298
|
+
const crossed = size ? [200, 150].find((t) => size.added > t) : undefined;
|
|
299
|
+
if (crossed && (s.size_warned || 0) < crossed) {
|
|
300
|
+
s.size_warned = crossed;
|
|
301
|
+
sizeNote = "karajan sentinel: la rama ya suma " + size.added + " lineas contables (" + size.testAdded + " de tests); " + (crossed >= 200 ? "pasa el limite de 200 del CI: parte antes de seguir" : "pasa de 150: planea la particion ahora, no en el stage");
|
|
302
|
+
}
|
|
303
|
+
}
|
|
291
304
|
const ids = Object.keys(state.sessions);
|
|
292
305
|
if (ids.length > 5) delete state.sessions[ids.sort((a, b) => (state.sessions[a].at || 0) - (state.sessions[b].at || 0))[0]];
|
|
293
306
|
save(state);
|
|
307
|
+
if (sizeNote) process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: sizeNote } }));
|
|
294
308
|
} catch { /* fail open — the sentinel never breaks a tool call */ }
|
|
295
309
|
process.exit(0);
|
|
296
310
|
});
|
package/src/rag/indexable.js
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* text windows (chunkSource already does).
|
|
9
9
|
*/
|
|
10
10
|
import { execFileSync } from "node:child_process";
|
|
11
|
-
import { closeSync, lstatSync, openSync, readSync } from "node:fs";
|
|
11
|
+
import { closeSync, lstatSync, openSync, readFileSync, readSync, statSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
12
13
|
|
|
13
14
|
import { matchesAny } from "@karajan-family/governance";
|
|
14
15
|
|
|
@@ -40,6 +41,35 @@ const skipsFor = (projectDir) => {
|
|
|
40
41
|
return skipCache.get(key);
|
|
41
42
|
};
|
|
42
43
|
|
|
44
|
+
/**
|
|
45
|
+
* KJC-TSK-0900: `.ragignore` at the project root, gitignore syntax, versioned so
|
|
46
|
+
* the whole team inherits it. `dir/` is a folder anywhere, `*.csv` an extension
|
|
47
|
+
* anywhere, `/data` anchored to the root, a path with a slash is from the root.
|
|
48
|
+
* `!` negation is not supported (said in the docs). Re-read when it changes.
|
|
49
|
+
*/
|
|
50
|
+
const ragignoreCache = new Map();
|
|
51
|
+
export function ragignoreGlobs(projectDir) {
|
|
52
|
+
if (!projectDir) return [];
|
|
53
|
+
const file = join(projectDir, ".ragignore");
|
|
54
|
+
let mtime;
|
|
55
|
+
try { mtime = statSync(file).mtimeMs; } catch { return []; }
|
|
56
|
+
const hit = ragignoreCache.get(file);
|
|
57
|
+
if (hit?.mtime === mtime) return hit.globs;
|
|
58
|
+
const globs = [];
|
|
59
|
+
for (const raw of readFileSync(file, "utf8").split("\n")) {
|
|
60
|
+
let p = raw.trim();
|
|
61
|
+
if (!p || p.startsWith("#") || p.startsWith("!")) continue;
|
|
62
|
+
const dirOnly = p.endsWith("/");
|
|
63
|
+
p = p.replace(/^\/+|\/+$/g, "");
|
|
64
|
+
if (!p) continue;
|
|
65
|
+
const base = raw.trim().startsWith("/") || p.includes("/") ? p : `**/${p}`;
|
|
66
|
+
globs.push(`${base}/**`);
|
|
67
|
+
if (!dirOnly) globs.push(base);
|
|
68
|
+
}
|
|
69
|
+
ragignoreCache.set(file, { mtime, globs });
|
|
70
|
+
return globs;
|
|
71
|
+
}
|
|
72
|
+
|
|
43
73
|
/** What the project keeps out of its index: `rag.exclude`, repo-relative globs. */
|
|
44
74
|
export const ragExclude = (config) => (Array.isArray(config?.rag?.exclude) ? config.rag.exclude : []);
|
|
45
75
|
|
|
@@ -63,6 +93,7 @@ function looksBinary(abs) {
|
|
|
63
93
|
export function indexableReason(rel, abs, { exclude = [], projectDir = null, skip = skipsFor(projectDir) } = {}) {
|
|
64
94
|
if (skip(rel)) return `${rel} lives under a path the indexer always skips`;
|
|
65
95
|
if (matchesAny(rel, exclude)) return `${rel} is excluded by the project's rag.exclude`;
|
|
96
|
+
if (matchesAny(rel, ragignoreGlobs(projectDir))) return `${rel} is excluded by the project's .ragignore`;
|
|
66
97
|
if (SENSITIVE.some((re) => re.test(rel))) return `${rel} may hold secrets, and the embedder can be a remote service`;
|
|
67
98
|
if (GENERATED.some((re) => re.test(rel))) return `${rel} is a generated file (lockfile, minified, sourcemap or snapshot)`;
|
|
68
99
|
let st;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-BUG-0235 — are the lines a diff ADDS only comments? A cleanup deletes
|
|
3
|
+
* code and corrects the comments that were left lying about it; those added
|
|
4
|
+
* lines carry no behavior to test. Decided by scanning the resulting file, not
|
|
5
|
+
* by a line's first characters: " * factor" can continue a product. Strings are
|
|
6
|
+
* kept as code, so a docstring or a `//` inside a string is never a comment.
|
|
7
|
+
* An unknown syntax is not judged: everything counts as code (conservative).
|
|
8
|
+
*/
|
|
9
|
+
import { extname } from "node:path";
|
|
10
|
+
|
|
11
|
+
// JSX/TSX and PHP are out: a `//` in a JSX text child or outside `<?php` is
|
|
12
|
+
// rendered content. JSX inside a .js/.ts file is detected below.
|
|
13
|
+
const C_STYLE = new Set([".js", ".mjs", ".cjs", ".ts", ".java", ".cs", ".go", ".kt", ".swift", ".c", ".cc", ".cpp", ".h", ".hpp", ".rs", ".scala"]);
|
|
14
|
+
// Shell and Ruby are left out on purpose: a `#` line inside a heredoc is
|
|
15
|
+
// content, not a comment, and this scanner does not parse heredocs.
|
|
16
|
+
const HASH_STYLE = new Set([".py"]);
|
|
17
|
+
const OPENER_AFTER = new Set([";", "{", "}", "(", ")", ",", "=", ":", "?", "!", "&", "|"]);
|
|
18
|
+
|
|
19
|
+
/** @returns {Set<number>|null} 1-based lines holding code, or null for an unknown syntax */
|
|
20
|
+
export function codeLines(content, file) {
|
|
21
|
+
const ext = extname(file).toLowerCase();
|
|
22
|
+
const cStyle = C_STYLE.has(ext);
|
|
23
|
+
if (!cStyle && !HASH_STYLE.has(ext)) return null;
|
|
24
|
+
const lines = new Set();
|
|
25
|
+
let line = 1;
|
|
26
|
+
let state = "code"; // code | line | block | string
|
|
27
|
+
let quote = "";
|
|
28
|
+
const s = String(content);
|
|
29
|
+
let step; // characters consumed by the current iteration (set in the body)
|
|
30
|
+
let prev = ""; // last non-space character seen in code on this line
|
|
31
|
+
let word = ""; // identifier being read in code
|
|
32
|
+
let lastWord = ""; // last complete identifier in code
|
|
33
|
+
for (let i = 0; i < s.length; i += step) {
|
|
34
|
+
const c = s[i];
|
|
35
|
+
const next = s[i + 1];
|
|
36
|
+
step = 1;
|
|
37
|
+
if (c === "\n") prev = "";
|
|
38
|
+
if (c === "\n") {
|
|
39
|
+
line++;
|
|
40
|
+
if (state === "line") state = "code";
|
|
41
|
+
// A line inside a multi-line string is part of its value, even if blank.
|
|
42
|
+
if (state === "string") lines.add(line);
|
|
43
|
+
} else if (state === "block") {
|
|
44
|
+
if (c === "*" && next === "/") { state = "code"; step = 2; }
|
|
45
|
+
} else if (state === "string") {
|
|
46
|
+
lines.add(line);
|
|
47
|
+
if (c === "\\") {
|
|
48
|
+
// An escaped newline continues the string on the next physical line: count it.
|
|
49
|
+
if (next === "\n") { line++; lines.add(line); }
|
|
50
|
+
step = 2;
|
|
51
|
+
} else if (c === quote) {
|
|
52
|
+
state = "code";
|
|
53
|
+
}
|
|
54
|
+
} else if (state === "code") {
|
|
55
|
+
// A comment opens only where one can: line start or after a separator.
|
|
56
|
+
// After `\`, `[` or an identifier a slash may belong to a regex literal
|
|
57
|
+
// (/a\/*b/, /[/*]/), so it is taken as code: in doubt, code.
|
|
58
|
+
const canOpen = prev === "" || OPENER_AFTER.has(prev);
|
|
59
|
+
if (/[\w$]/.test(c)) word += c;
|
|
60
|
+
else if (word) { lastWord = word; word = ""; }
|
|
61
|
+
// A tag where an expression starts (`= <div>`, `return <App />`) is JSX:
|
|
62
|
+
// its text children are rendered, so this file is not judged.
|
|
63
|
+
if (cStyle && c === "<" && /[A-Za-z/>]/.test(next ?? "") && (canOpen || lastWord === "return")) return null;
|
|
64
|
+
if (cStyle && canOpen && c === "/" && next === "/") state = "line";
|
|
65
|
+
else if (cStyle && canOpen && c === "/" && next === "*") { state = "block"; step = 2; }
|
|
66
|
+
else if (!cStyle && c === "#") state = "line";
|
|
67
|
+
else if (c === '"' || c === "'" || c === "`") { state = "string"; quote = c; lines.add(line); }
|
|
68
|
+
else if (!/\s/.test(c)) lines.add(line);
|
|
69
|
+
if (state === "code" && !/\s/.test(c)) prev = c;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return lines;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const COMMENT_START = /^(\/\/|\/\*|\*|#)/;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Every added line (1-based, in the resulting file) is a comment or blank.
|
|
79
|
+
* Two checks must agree: the scanner says no code on the line AND the line
|
|
80
|
+
* itself starts like a comment (or is blank). The second one is a net against
|
|
81
|
+
* a scanner mistake, and it costs a knowingly accepted false negative: a block
|
|
82
|
+
* comment continuation without a leading `*` counts as code. Agreed criterion
|
|
83
|
+
* (KJC-BUG-0235): better a cleanup that still asks for tests than code let through.
|
|
84
|
+
*/
|
|
85
|
+
export function addedAreCommentsOnly(content, addedLines, file) {
|
|
86
|
+
const code = codeLines(content, file);
|
|
87
|
+
if (!code) return false;
|
|
88
|
+
const text = String(content).split("\n");
|
|
89
|
+
return [...addedLines].every((n) => {
|
|
90
|
+
const t = (text[n - 1] ?? "").trim();
|
|
91
|
+
return !code.has(n) && (t === "" || COMMENT_START.test(t));
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -42,8 +42,11 @@ export function checkTestsWithCode({ config = {}, stagedFiles = [], numstat = nu
|
|
|
42
42
|
// cleanup PRs). Callers that only know names keep the old behavior.
|
|
43
43
|
if (Array.isArray(numstat)) {
|
|
44
44
|
const bySource = numstat.filter((n) => sources.includes(n.file));
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
// KJC-BUG-0235: a cleanup also corrects the comments the deletion left
|
|
46
|
+
// lying; lines the caller PROVED to be comments carry no behavior either.
|
|
47
|
+
const cleanup = (n) => (n.added || 0) === 0 || n.commentOnly === true;
|
|
48
|
+
if (bySource.length === sources.length && bySource.every(cleanup)) {
|
|
49
|
+
return { ok: true, mode: "delete-only", sources, reason: "every touched source only removes lines or corrects comments — deleting is not new behavior" };
|
|
47
50
|
}
|
|
48
51
|
}
|
|
49
52
|
|