karajan-code 4.38.0 → 4.40.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/packages/ai-trash/src/destructive-parser.js +34 -5
- package/packages/ai-trash/src/hook.js +28 -6
- package/src/audit/ai-slop-findings.js +4 -2
- package/src/audit/circular-deps.js +8 -7
- package/src/audit/webperf-input.js +3 -1
- package/src/checks/method.js +1 -1
- package/src/checks/release-check.js +3 -1
- package/src/cli/advanced-commands.js +1 -1
- package/src/cli/register-meta.js +112 -1
- package/src/cli/register-pipeline.js +2 -1
- package/src/commands/init.js +6 -2
- package/src/commands/review-gate.js +21 -22
- package/src/commands/rules-approve.js +59 -0
- package/src/commands/rules-compile.js +116 -0
- package/src/commands/rules-decide.js +51 -0
- package/src/commands/rules-review.js +44 -0
- package/src/commands/rules.js +169 -0
- package/src/config/loader.js +23 -1
- package/src/environment/adr.js +5 -2
- package/src/harden/config-templates.js +3 -0
- package/src/harden/harness-hooks.js +7 -85
- package/src/harden/hook-templates.js +6 -5
- package/src/harden/human-act.js +70 -0
- package/src/harden/phone-sign.js +26 -0
- package/src/harden/sentinel/pretooluse-rules.mjs +25 -0
- package/src/harden/sentinel/sentinel-bash-write.mjs +2 -6
- package/src/harden/sentinel/sentinel-discard.mjs +33 -4
- package/src/harden/sentinel/sentinel-rules.mjs +86 -0
- package/src/harden/sentinel/sentinel-shell.mjs +34 -2
- package/src/harden/sentinel/sessionstart.mjs +18 -9
- package/src/harden/sentinel-hooks.js +111 -194
- package/src/harden/supervisor-commit.js +13 -56
- package/src/mcp/handlers/run-handler.js +5 -0
- package/src/mcp/sovereignty-guard.js +16 -15
- package/src/orchestrator/preflight-checks.js +4 -3
- package/src/policy/supervisor-verify.js +20 -6
- package/src/privacy/scan.js +7 -0
- package/src/review/card-first.js +3 -5
- package/src/review/gate-gitignore.js +8 -0
- package/src/review/policy-gate.js +4 -6
- package/src/review/sonar-pregate.js +3 -3
- package/src/review/tests-with-code.js +3 -6
- package/src/rules/approval-view.js +49 -0
- package/src/rules/compiled.js +108 -0
- package/src/rules/coverage.js +23 -0
- package/src/rules/evaluate.js +60 -0
- package/src/rules/inventory.js +118 -0
- package/src/sonar/api.js +21 -0
- package/src/sonar/config-resolver.js +19 -1
- package/src/sonar/scanner.js +22 -3
- package/src/utils/run-log.js +74 -2
- package/src/utils/stack-detect.js +12 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The deterministic evaluator of compiled rules (KJC-TSK-0944, MDR-B1, ADR 0016):
|
|
3
|
+
* one tool call against the rules parseRules accepted, with no model.
|
|
4
|
+
*
|
|
5
|
+
* A rule says when to deny. A condition over an argument the call does not
|
|
6
|
+
* carry, or cannot be read as the operator needs (a date, a number), does not
|
|
7
|
+
* hold: the rule states a fact about the call, and that fact is not there.
|
|
8
|
+
*/
|
|
9
|
+
import { isObject, toolsOf } from "./compiled.js";
|
|
10
|
+
|
|
11
|
+
const DAY_MS = 86_400_000;
|
|
12
|
+
|
|
13
|
+
/** A tool glob: `*` stands for any run of characters, the rest is literal. */
|
|
14
|
+
const toolMatches = (glob, tool) => {
|
|
15
|
+
const literal = glob.split("*").map((part) => part.replaceAll(/[.+?^${}()|[\]\\]/g, String.raw`\$&`));
|
|
16
|
+
return new RegExp(`^${literal.join(".*")}$`).test(String(tool));
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** The value at a dotted path of the tool input, own properties only. */
|
|
20
|
+
const at = (input, dotted) =>
|
|
21
|
+
dotted.split(".").reduce((node, key) => (isObject(node) && Object.hasOwn(node, key) ? node[key] : undefined), input);
|
|
22
|
+
|
|
23
|
+
/** Calendar days (UTC) from one date to another: the same day is 0, whatever the hour. */
|
|
24
|
+
const daysBetween = (from, to) => Math.floor(Date.parse(to) / DAY_MS) - Math.floor(Date.parse(from) / DAY_MS);
|
|
25
|
+
|
|
26
|
+
function holds(cond, input) {
|
|
27
|
+
let value = at(input, cond.arg);
|
|
28
|
+
const present = value !== undefined && value !== null;
|
|
29
|
+
if ("exists" in cond) return present === cond.exists;
|
|
30
|
+
if (!present) return false;
|
|
31
|
+
if ("days_from" in cond) value = daysBetween(at(input, cond.days_from), value);
|
|
32
|
+
if ("equals" in cond) return value === cond.equals;
|
|
33
|
+
if ("in" in cond) return cond.in.includes(value);
|
|
34
|
+
if ("matches" in cond) return typeof value === "string" && new RegExp(cond.matches).test(value);
|
|
35
|
+
if (typeof value !== "number" || Number.isNaN(value)) return false;
|
|
36
|
+
return "gt" in cond ? value > cond.gt : value < cond.lt;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const applies = (rule, tool, input) =>
|
|
40
|
+
toolsOf(rule.when).some((glob) => toolMatches(glob, tool))
|
|
41
|
+
&& (rule.when.all ?? []).every((cond) => holds(cond, input))
|
|
42
|
+
&& (!rule.when.any?.length || rule.when.any.some((cond) => holds(cond, input)));
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {object[]} rules what parseRules returned
|
|
46
|
+
* @param {{tool: string, input?: object}} call
|
|
47
|
+
* @returns {{decision: "allow"} | {decision: "deny", rule_id: string, source: string|null, text: string|null, message: string}}
|
|
48
|
+
*/
|
|
49
|
+
export function evalRules(rules, { tool, input }) {
|
|
50
|
+
const args = isObject(input) ? input : {};
|
|
51
|
+
const hit = rules.find((rule) => rule.kind === "deterministic" && applies(rule, tool, args));
|
|
52
|
+
if (!hit) return { decision: "allow" };
|
|
53
|
+
return {
|
|
54
|
+
decision: "deny",
|
|
55
|
+
rule_id: hit.id,
|
|
56
|
+
source: hit.source ?? null,
|
|
57
|
+
text: hit.text ?? null,
|
|
58
|
+
message: hit.message ?? hit.text ?? `rule ${hit.id}`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule inventory (KJC-TSK-0937, MDR-A, ADR 0016). Every rule written in the MD
|
|
3
|
+
* files that govern a session, with a stable id, its literal text and where it
|
|
4
|
+
* lives, so each one can get a gate and a deny can cite it.
|
|
5
|
+
*
|
|
6
|
+
* A rule is a markdown block (a list item with the lines that continue it, or a
|
|
7
|
+
* paragraph), outside code blocks, that carries a normative marker. The id hashes
|
|
8
|
+
* the normalized text, so a rule keeps its id when it moves or is wrapped anew.
|
|
9
|
+
*/
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
|
|
15
|
+
const NORMATIVE = /\b(nunca|siempre|prohibido|obligatorio|jam[aá]s|never|always|must|forbidden|required|do not|don't)\b/i;
|
|
16
|
+
|
|
17
|
+
// Emphasis delimiters only: a marker opens after a blank and closes before one (or
|
|
18
|
+
// punctuation), so snake_case names and globs (docs/**/*.md) keep their characters.
|
|
19
|
+
const stripEmphasis = (text) => text
|
|
20
|
+
.replaceAll(/(^|[\s(])\*\*(\S[^*]*)\*\*(?=[\s).,;:!?]|$)/g, "$1$2")
|
|
21
|
+
.replaceAll(/(^|[\s(])__(\S[^_]*)__(?=[\s).,;:!?]|$)/g, "$1$2")
|
|
22
|
+
.replaceAll(/(^|[\s(])\*(\S[^*]*)\*(?=[\s).,;:!?]|$)/g, "$1$2")
|
|
23
|
+
.replaceAll(/(^|[\s(])_(\S[^_]*)_(?=[\s).,;:!?]|$)/g, "$1$2");
|
|
24
|
+
|
|
25
|
+
const LIST_ITEM = /^\s*(?:[-*+]|\d+[.)])\s+/;
|
|
26
|
+
|
|
27
|
+
const HELD =""; // private-use char: stands in for a code span while emphasis is stripped
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The text of a markdown line without its list marker, emphasis or code ticks.
|
|
31
|
+
* What sits inside a code span is kept as written, whatever characters it holds.
|
|
32
|
+
*/
|
|
33
|
+
const plain = (line) => {
|
|
34
|
+
const spans = [];
|
|
35
|
+
const held = line
|
|
36
|
+
.replace(LIST_ITEM, "")
|
|
37
|
+
.replaceAll(/`([^`]*)`/g, (_m, code) => `${HELD}${spans.push(code) - 1}${HELD}`);
|
|
38
|
+
return stripEmphasis(held)
|
|
39
|
+
.replaceAll(new RegExp(`${HELD}(\\d+)${HELD}`, "g"), (_m, n) => spans[Number(n)])
|
|
40
|
+
.replaceAll(/\s+/g, " ")
|
|
41
|
+
.trim();
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const ruleId = (text) => "R-" + createHash("sha256").update(text.toLowerCase()).digest("hex").slice(0, 10);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {string} markdown
|
|
48
|
+
* @param {string} file
|
|
49
|
+
* @returns {{id: string, text: string, file: string, line: number}[]}
|
|
50
|
+
*/
|
|
51
|
+
export function extractRules(markdown, file) {
|
|
52
|
+
const rules = [];
|
|
53
|
+
let fence = null; // the open code fence (``` or ~~~, any length), closed by one as long
|
|
54
|
+
let comment = false; // inside an HTML comment that has not closed yet
|
|
55
|
+
let block = null; // the list item or paragraph being read: { line, parts }
|
|
56
|
+
const flush = () => {
|
|
57
|
+
const text = block ? plain(block.parts.join(" ")) : "";
|
|
58
|
+
if (text && NORMATIVE.test(text)) rules.push({ id: ruleId(text), text, file, line: block.line });
|
|
59
|
+
block = null;
|
|
60
|
+
};
|
|
61
|
+
const lines = markdown.split("\n");
|
|
62
|
+
// A memory file's YAML frontmatter is metadata, not rules.
|
|
63
|
+
const frontmatterEnd = lines[0]?.trim() === "---" ? lines.indexOf("---", 1) : -1;
|
|
64
|
+
lines.forEach((raw, i) => {
|
|
65
|
+
if (i <= frontmatterEnd) return;
|
|
66
|
+
const mark = /^\s*(`{3,}|~{3,})/.exec(raw)?.[1];
|
|
67
|
+
if (mark && !fence) { flush(); fence = mark; return; }
|
|
68
|
+
if (mark && mark[0] === fence[0] && mark.length >= fence.length && raw.trim() === mark) { fence = null; return; }
|
|
69
|
+
if (fence) return;
|
|
70
|
+
if (comment || /^\s*<!--/.test(raw)) { flush(); comment = !raw.includes("-->"); return; }
|
|
71
|
+
if (!raw.trim() || /^\s*#/.test(raw) || /^\s*\|/.test(raw)) { flush(); return; }
|
|
72
|
+
// KJC-BUG-0271: the unit is the markdown block. A list marker opens a new
|
|
73
|
+
// one; any other line continues the block it follows (a wrapped rule).
|
|
74
|
+
if (LIST_ITEM.test(raw)) flush();
|
|
75
|
+
block ??= { line: i + 1, parts: [] };
|
|
76
|
+
block.parts.push(raw.trim());
|
|
77
|
+
});
|
|
78
|
+
flush();
|
|
79
|
+
return rules;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The MD files that govern a session in projectDir (KJC-TSK-0942): its CLAUDE.md
|
|
84
|
+
* and AGENTS.md, the global ~/.claude/CLAUDE.md and the project's feedback memories.
|
|
85
|
+
* @param {string} projectDir
|
|
86
|
+
* @param {{home?: string}} [opts]
|
|
87
|
+
*/
|
|
88
|
+
export function ruleSources(projectDir, { home = os.homedir() } = {}) {
|
|
89
|
+
// Claude Code names a project's folder after its path, every non-alphanumeric char as "-".
|
|
90
|
+
const memory = path.join(home, ".claude", "projects", path.resolve(projectDir).replaceAll(/[^A-Za-z0-9]/g, "-"), "memory");
|
|
91
|
+
let feedback = [];
|
|
92
|
+
try {
|
|
93
|
+
feedback = fs.readdirSync(memory).filter((f) => /^feedback_.*\.md$/.test(f)).sort().map((f) => path.join(memory, f));
|
|
94
|
+
} catch { /* no memories for this project */ }
|
|
95
|
+
return [
|
|
96
|
+
path.join(projectDir, "CLAUDE.md"),
|
|
97
|
+
path.join(projectDir, "AGENTS.md"),
|
|
98
|
+
path.join(home, ".claude", "CLAUDE.md"),
|
|
99
|
+
...feedback,
|
|
100
|
+
].filter((f) => fs.existsSync(f));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Whether an MD file is part of the project, and so as public as the repo is. */
|
|
104
|
+
export const inProject = (file, projectDir) => {
|
|
105
|
+
const rel = path.relative(projectDir, file);
|
|
106
|
+
return !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** A source as a rules file can carry it: from the project's root, or from `~`. */
|
|
110
|
+
export const shownSource = (file, projectDir, home = os.homedir()) => {
|
|
111
|
+
if (inProject(file, projectDir)) return path.relative(projectDir, file);
|
|
112
|
+
return inProject(file, home) ? path.join("~", path.relative(home, file)) : file;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/** Every rule of every governing MD file, in source order. */
|
|
116
|
+
export function listRules(projectDir, opts = {}) {
|
|
117
|
+
return ruleSources(projectDir, opts).flatMap((file) => extractRules(fs.readFileSync(file, "utf8"), file));
|
|
118
|
+
}
|
package/src/sonar/api.js
CHANGED
|
@@ -82,6 +82,27 @@ async function sonarFetch(config, urlPath) {
|
|
|
82
82
|
);
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
const CE_FINAL = new Set(["SUCCESS", "FAILED", "CANCELED"]);
|
|
86
|
+
const sleepMs = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* KJC-BUG-0263 — the scanner uploads the analysis and returns; the server
|
|
90
|
+
* processes it in the background, and issues read before that task ends are
|
|
91
|
+
* the PREVIOUS analysis. Polls /api/ce/task until it reaches a final status.
|
|
92
|
+
* @returns {Promise<{ok: boolean, status: string, timedOut?: boolean}>}
|
|
93
|
+
*/
|
|
94
|
+
export async function waitForAnalysis(config, taskId, { timeoutMs = 120_000, intervalMs = 1000, sleep = sleepMs } = {}) {
|
|
95
|
+
const deadline = Date.now() + timeoutMs;
|
|
96
|
+
let status = "UNKNOWN";
|
|
97
|
+
while (Date.now() < deadline) {
|
|
98
|
+
const body = await sonarFetch(config, `/api/ce/task?id=${encodeURIComponent(taskId)}`);
|
|
99
|
+
try { status = JSON.parse(body).task?.status || "UNKNOWN"; } catch { status = "UNKNOWN"; }
|
|
100
|
+
if (CE_FINAL.has(status)) return { ok: status === "SUCCESS", status };
|
|
101
|
+
await sleep(intervalMs);
|
|
102
|
+
}
|
|
103
|
+
return { ok: false, status, timedOut: true };
|
|
104
|
+
}
|
|
105
|
+
|
|
85
106
|
/**
|
|
86
107
|
* KJC-BUG-0226 (issue #1838) — de donde sale la clave que se CONSULTA. El
|
|
87
108
|
* scanner sube el analisis con la que el repo declara en su
|
|
@@ -5,7 +5,14 @@
|
|
|
5
5
|
* Consumers: scanner.js, preflight-checks.js, api.js.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
8
10
|
import { loadSonarCredentials } from "./credentials.js";
|
|
11
|
+
import { getKarajanHome } from "../utils/paths.js";
|
|
12
|
+
|
|
13
|
+
/** KJC-BUG-0266: the places a token is looked for, named in every "no token" message. */
|
|
14
|
+
export const sonarTokenSources = () =>
|
|
15
|
+
`KJ_SONAR_TOKEN, sonarqube.token in kj.config.yml, SONAR_TOKEN, ${path.join(getKarajanHome(), "sonar-credentials.json")} or ${sonarTokenFilePath()}`;
|
|
9
16
|
|
|
10
17
|
const DEFAULT_HOST = "http://localhost:9000";
|
|
11
18
|
|
|
@@ -54,7 +61,18 @@ export async function resolveSonarTokenAsync(config = {}) {
|
|
|
54
61
|
|
|
55
62
|
// Check credentials file for persisted token
|
|
56
63
|
const fileCreds = (await loadSonarCredentials()) || {};
|
|
57
|
-
return fileCreds.token || null;
|
|
64
|
+
return fileCreds.token || readSonarTokenFile() || null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Where the token bootstrap saves the token (mode 0600), in the active Karajan home. */
|
|
68
|
+
export const sonarTokenFilePath = () => path.join(getKarajanHome(), "sonar.token");
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* KJC-BUG-0266: the token bootstrap persisted the token to <karajan home>/sonar.token
|
|
72
|
+
* and no resolver read it back, so a home with only that file looked tokenless.
|
|
73
|
+
*/
|
|
74
|
+
export function readSonarTokenFile() {
|
|
75
|
+
try { return fs.readFileSync(sonarTokenFilePath(), "utf8").trim() || null; } catch { return null; }
|
|
58
76
|
}
|
|
59
77
|
|
|
60
78
|
/**
|
package/src/sonar/scanner.js
CHANGED
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import { runCommand } from "../utils/process.js";
|
|
6
6
|
import { sonarUp } from "./manager.js";
|
|
7
|
+
import { waitForAnalysis } from "./api.js";
|
|
7
8
|
import { resolveSonarProjectKey } from "./project-key.js";
|
|
8
9
|
import {
|
|
9
10
|
resolveSonarHost,
|
|
@@ -380,14 +381,32 @@ export async function runSonarScan(config, projectKey = null, { verbose = false,
|
|
|
380
381
|
}
|
|
381
382
|
|
|
382
383
|
const result = await runCommand(cmd, args, { timeout: scannerTimeout, env, cwd: scanCwd });
|
|
384
|
+
// KJC-BUG-0263: the scan is done when the server has processed it; whoever reads
|
|
385
|
+
// the issues next would otherwise get the previous analysis. Said out loud when
|
|
386
|
+
// the log names no task to wait for.
|
|
387
|
+
let ok = result.exitCode === 0;
|
|
388
|
+
let analysisNote = null;
|
|
389
|
+
if (ok) {
|
|
390
|
+
const taskId = /api\/ce\/task\?id=([\w-]+)/.exec(String(result.stdout || ""))?.[1];
|
|
391
|
+
if (taskId) {
|
|
392
|
+
const done = await waitForAnalysis({ ...config, sonarqube: { ...sonarConfig, host: apiHost } }, taskId);
|
|
393
|
+
if (!done.ok) {
|
|
394
|
+
ok = false;
|
|
395
|
+
analysisNote = `sonar: the server did not finish analysis ${taskId} (${done.status}${done.timedOut ? ", timed out" : ""}) — its issues would be the previous analysis`;
|
|
396
|
+
}
|
|
397
|
+
} else {
|
|
398
|
+
analysisNote = "sonar: the scanner log names no analysis task, so kj could not wait for the server — issues may belong to the previous analysis";
|
|
399
|
+
}
|
|
400
|
+
}
|
|
383
401
|
return {
|
|
384
|
-
ok
|
|
402
|
+
ok,
|
|
385
403
|
projectKey: effectiveProjectKey,
|
|
386
404
|
cwd: scanCwd,
|
|
387
405
|
scanner: pick.type,
|
|
388
|
-
note,
|
|
406
|
+
note: [note, analysisNote].filter(Boolean).join("; ") || null,
|
|
389
407
|
stdout: result.stdout,
|
|
390
|
-
|
|
408
|
+
// A scan that failed on the server's side says so, not with the scanner's own log.
|
|
409
|
+
stderr: !ok && result.exitCode === 0 ? analysisNote : result.stderr,
|
|
391
410
|
exitCode: result.exitCode
|
|
392
411
|
};
|
|
393
412
|
}
|
package/src/utils/run-log.js
CHANGED
|
@@ -8,10 +8,12 @@
|
|
|
8
8
|
* Log location: <projectDir>/.kj/run.log (overwritten each run)
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
11
12
|
import fs from "node:fs";
|
|
12
13
|
import path from "node:path";
|
|
13
14
|
|
|
14
15
|
const LOG_FILENAME = "run.log";
|
|
16
|
+
const LOCK_FILENAME = "run.lock";
|
|
15
17
|
|
|
16
18
|
function resolveLogDir(baseDir) {
|
|
17
19
|
return path.join(baseDir || process.cwd(), ".kj");
|
|
@@ -54,8 +56,15 @@ export function createRunLog(projectDir) {
|
|
|
54
56
|
const logDir = resolveLogDir(projectDir);
|
|
55
57
|
ensureDir(logDir);
|
|
56
58
|
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
+
// KJC-BUG-0250: the run lock, taken atomically and owned by this run's token.
|
|
60
|
+
// close() releases it only while it is still ours (every caller closes in a
|
|
61
|
+
// finally); a lock whose process is gone is stale and taken over.
|
|
62
|
+
const startedAt = new Date().toISOString();
|
|
63
|
+
const lockPath = path.join(logDir, LOCK_FILENAME);
|
|
64
|
+
const token = randomUUID();
|
|
65
|
+
const owned = acquireLock(lockPath, { pid: process.pid, startedAt, token });
|
|
66
|
+
// Truncate/create the log file, unless another live run owns it.
|
|
67
|
+
if (owned) fs.writeFileSync(logPath, `--- Karajan run started at ${startedAt} ---\n`);
|
|
59
68
|
|
|
60
69
|
// Audit recommendation (perf-LOW): suggested createWriteStream + queue.
|
|
61
70
|
// Tried it; broke the contract. `readRunLog` (used by `kj_status`,
|
|
@@ -102,16 +111,79 @@ export function createRunLog(projectDir) {
|
|
|
102
111
|
fd = null;
|
|
103
112
|
}
|
|
104
113
|
} catch { /* best-effort */ }
|
|
114
|
+
if (owned && readLock(lockPath)?.token === token) {
|
|
115
|
+
try { fs.rmSync(lockPath, { force: true }); } catch { /* best-effort */ }
|
|
116
|
+
}
|
|
105
117
|
}
|
|
106
118
|
|
|
107
119
|
return {
|
|
108
120
|
logEvent,
|
|
109
121
|
logText,
|
|
110
122
|
close,
|
|
123
|
+
/** false when another live run already held the project */
|
|
124
|
+
owned,
|
|
111
125
|
get path() { return logPath; }
|
|
112
126
|
};
|
|
113
127
|
}
|
|
114
128
|
|
|
129
|
+
const readLock = (lockPath) => {
|
|
130
|
+
try { return JSON.parse(fs.readFileSync(lockPath, "utf8")); } catch { return null; }
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export const isPidAlive = (pid) => {
|
|
134
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
135
|
+
try { process.kill(pid, 0); return true; } catch (err) { return err.code === "EPERM"; }
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const createExclusive = (file, body) => {
|
|
139
|
+
try { fs.writeFileSync(file, body, { flag: "wx" }); return true; } catch { return false; }
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// The lock appears with its whole content or not at all: written to a private
|
|
143
|
+
// file first, then hard-linked into place (link fails if the path exists), so
|
|
144
|
+
// nobody can read a half-written lock and take it for stale.
|
|
145
|
+
function linkLock(lockPath, body, token) {
|
|
146
|
+
const tmp = `${lockPath}.${token}.tmp`;
|
|
147
|
+
try {
|
|
148
|
+
fs.writeFileSync(tmp, body);
|
|
149
|
+
fs.linkSync(tmp, lockPath);
|
|
150
|
+
return true;
|
|
151
|
+
} catch {
|
|
152
|
+
return false;
|
|
153
|
+
} finally {
|
|
154
|
+
try { fs.rmSync(tmp, { force: true }); } catch { /* leftover, harmless */ }
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function acquireLock(lockPath, holder) {
|
|
159
|
+
const body = JSON.stringify(holder);
|
|
160
|
+
if (linkLock(lockPath, body, holder.token)) return true;
|
|
161
|
+
if (isPidAlive(readLock(lockPath)?.pid)) return false;
|
|
162
|
+
// Stale (its process is gone, or unreadable). Only one racer at a time may take
|
|
163
|
+
// it over: the takeover mutex is itself an exclusive create. Inside it the lock
|
|
164
|
+
// is read again and replaced only if it is still stale; nobody else removes it,
|
|
165
|
+
// and the exclusive create that follows loses to anyone who got there first.
|
|
166
|
+
const mutex = `${lockPath}.takeover`;
|
|
167
|
+
clearStaleMutex(mutex);
|
|
168
|
+
if (!createExclusive(mutex, holder.token)) return false;
|
|
169
|
+
try {
|
|
170
|
+
if (isPidAlive(readLock(lockPath)?.pid)) return false;
|
|
171
|
+
try { fs.rmSync(lockPath, { force: true }); } catch { return false; }
|
|
172
|
+
return linkLock(lockPath, body, holder.token);
|
|
173
|
+
} finally {
|
|
174
|
+
try { fs.rmSync(mutex, { force: true }); } catch { /* leftover, cleared when stale */ }
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// A takeover takes milliseconds; a mutex older than this was left by a process
|
|
179
|
+
// that died inside one.
|
|
180
|
+
const TAKEOVER_STALE_MS = 10_000;
|
|
181
|
+
function clearStaleMutex(mutex) {
|
|
182
|
+
try {
|
|
183
|
+
if (Date.now() - fs.statSync(mutex).mtimeMs > TAKEOVER_STALE_MS) fs.rmSync(mutex, { force: true });
|
|
184
|
+
} catch { /* none */ }
|
|
185
|
+
}
|
|
186
|
+
|
|
115
187
|
const KJ_TOOLS = ["kj_run", "kj_code", "kj_plan"];
|
|
116
188
|
|
|
117
189
|
function detectRunStart(line, status) {
|
|
@@ -46,6 +46,9 @@ const LANGUAGE_FILE_MARKERS = [
|
|
|
46
46
|
{ file: "Package.swift", language: "swift", type: "backend" },
|
|
47
47
|
];
|
|
48
48
|
|
|
49
|
+
/** A page served as it is: where a static site (or the static half of an app) keeps it. */
|
|
50
|
+
const STATIC_SITE_MARKERS = ["index.html", path.join("public", "index.html"), path.join("src", "index.html")];
|
|
51
|
+
|
|
49
52
|
/**
|
|
50
53
|
* Maps framework names to skill names for auto-install suggestions.
|
|
51
54
|
*/
|
|
@@ -109,6 +112,15 @@ export async function detectProjectStack(projectDir) {
|
|
|
109
112
|
} catch { /* file not found */ }
|
|
110
113
|
}
|
|
111
114
|
|
|
115
|
+
// 3. KJC-BUG-0267: a static site has a frontend with no framework to show for it.
|
|
116
|
+
for (const page of STATIC_SITE_MARKERS) {
|
|
117
|
+
try {
|
|
118
|
+
await fs.access(path.join(projectDir, page));
|
|
119
|
+
hasFrontend = true;
|
|
120
|
+
break;
|
|
121
|
+
} catch { /* file not found */ }
|
|
122
|
+
}
|
|
123
|
+
|
|
112
124
|
const frameworkList = Array.from(frameworks);
|
|
113
125
|
const isFullstack = hasFrontend && hasBackend;
|
|
114
126
|
|