ruvnet-brain 4.7.22 → 4.7.24
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 +1 -1
- package/bin/install.mjs +59 -1
- package/kb/lifecycle-evidence-retention.mjs +1 -1
- package/kb/update-storage-transaction.mjs +161 -1
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/scripts/brain-footprint.mjs +5 -1
- package/plugin/scripts/learn-capture.mjs +26 -3
- package/scripts/generation-census.mjs +220 -0
- package/scripts/issue-autofix-agents.mjs +172 -0
- package/scripts/issue-autofix-comment.mjs +224 -0
- package/scripts/issue-autofix-diff.mjs +108 -0
- package/scripts/issue-autofix-exec.mjs +128 -0
- package/scripts/issue-autofix-fix.mjs +104 -0
- package/scripts/issue-autofix-gate.mjs +137 -0
- package/scripts/issue-autofix-night.mjs +164 -0
- package/scripts/issue-autofix-notes.mjs +346 -0
- package/scripts/issue-autofix-policy.mjs +349 -0
- package/scripts/issue-autofix-prompts.mjs +105 -0
- package/scripts/issue-autofix-release-ship.mjs +226 -0
- package/scripts/issue-autofix-release-util.mjs +141 -0
- package/scripts/issue-autofix-release.mjs +272 -0
- package/scripts/issue-autofix-report.mjs +121 -0
- package/scripts/issue-autofix-runner.mjs +75 -0
- package/scripts/issue-autofix-sandbox.mjs +108 -0
- package/scripts/issue-autofix-security.mjs +346 -0
- package/scripts/issue-autofix-selftest.mjs +101 -0
- package/scripts/issue-autofix-state.mjs +61 -0
- package/scripts/issue-autofix.mjs +382 -0
- package/scripts/issue-fix-lock.mjs +40 -0
- package/scripts/issue-fix.mjs +33 -20
- package/scripts/no-silent-substitution.mjs +1 -1
- package/scripts/ntfy-inbox.mjs +15 -0
- package/scripts/onboarding-console.mjs +6 -1
- package/scripts/release-prepush-checks.mjs +251 -0
- package/scripts/release-prepush.mjs +168 -0
- package/scripts/release-qualification-contract.mjs +32 -0
- package/scripts/wired-check.mjs +11 -5
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// scripts/issue-autofix-agents.mjs — how the daily autofix job (ADR-107) spawns `claude -p`.
|
|
2
|
+
//
|
|
3
|
+
// Four LLM stages (plan / build / verify / note) with no git, gh or network access: the orchestrator does every
|
|
4
|
+
// push and API call itself. This module only decides WHAT is spawned (exact argv, tool allowlist, model alias,
|
|
5
|
+
// stripped environment, wall clock) and PROVES what answered (the model named in the result, not the one we asked
|
|
6
|
+
// for). It never asks the CLI to bypass permissions: least-privilege --allowedTools instead.
|
|
7
|
+
//
|
|
8
|
+
// Verified live against claude 2.1.312 (2026-10-29): --max-turns (undocumented in --help but accepted),
|
|
9
|
+
// --json-schema with --output-format json returns the object in `structured_output` and the models that served the
|
|
10
|
+
// call as the keys of `modelUsage`.
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
import { AutofixAbort } from './issue-autofix-policy.mjs';
|
|
16
|
+
|
|
17
|
+
// NO stage can run a command: Bash is absent from every tool list, and --restricted removes every code-running tool
|
|
18
|
+
// that --tools does not name. Code a model wrote runs only in the orchestrator, under the macOS sandbox
|
|
19
|
+
// (issue-autofix-sandbox.mjs), after the static security scan. The note and red-team stages get no tools at all and
|
|
20
|
+
// run in an empty temporary directory: they see only what the prompt hands them.
|
|
21
|
+
export const STAGE_CONFIG = Object.freeze({
|
|
22
|
+
plan: { model: 'opus', maxTurns: 40, timeoutMs: 20 * 60_000, tools: ['Read', 'Glob', 'Grep'], schema: 'config/issue-autofix/plan.schema.json', family: /opus/ },
|
|
23
|
+
build: { model: 'sonnet', maxTurns: 80, timeoutMs: 45 * 60_000, tools: ['Read', 'Edit', 'Write', 'Glob', 'Grep'], schema: null, family: /sonnet|opus/ },
|
|
24
|
+
verify: { model: 'opus', maxTurns: 60, timeoutMs: 30 * 60_000, tools: ['Read', 'Glob', 'Grep'], schema: 'config/issue-autofix/verify.schema.json', family: /opus/ },
|
|
25
|
+
redteam: { model: 'opus', maxTurns: 4, timeoutMs: 15 * 60_000, tools: [], schema: 'config/issue-autofix/redteam.schema.json', family: /opus/ },
|
|
26
|
+
note: { model: 'opus', maxTurns: 4, timeoutMs: 5 * 60_000, tools: [], schema: 'config/issue-autofix/note.schema.json', family: /opus/ },
|
|
27
|
+
});
|
|
28
|
+
export const GRACE_MS = 20_000;
|
|
29
|
+
const MAX_STDOUT = 32 * 1024 * 1024;
|
|
30
|
+
const NO_MCP = 'config/issue-autofix/no-mcp.json';
|
|
31
|
+
|
|
32
|
+
// BILLING SAFETY (issue-fix.mjs): an ambient API key either fails the run (stale key) or bills it. The git/gh
|
|
33
|
+
// credentials go too, so an LLM stage that somehow got a shell still cannot push or comment.
|
|
34
|
+
export const STRIPPED_ENV = [/^ANTHROPIC_/, /^CLAUDE_API_KEY$/, /^CLAUDE_CODE_USE_/, /^GH_TOKEN$/, /^GITHUB_TOKEN$/, /^GH_ENTERPRISE_TOKEN$/, /^SSH_AUTH_SOCK$/];
|
|
35
|
+
|
|
36
|
+
// SUBSCRIPTION LOGIN UNDER LAUNCHD (measured 2026-10-30). Under launchd the login keychain is readable, and the
|
|
37
|
+
// "Claude Code-credentials" item there was an EXPIRED OAuth session that could not refresh: `claude auth status`
|
|
38
|
+
// said loggedIn false. Claude Code's file login (~/.claude/.credentials.json), which the owner's own sessions keep
|
|
39
|
+
// fresh, is the one that works; claude uses it when the keychain cannot be reached. So on macOS every stage runs
|
|
40
|
+
// with keychain lookups denied: measured under a test LaunchAgent, loggedIn true (max) and served by opus. It also
|
|
41
|
+
// means no stage can read any other keychain item. No API key is ever used (BILLING SAFETY above).
|
|
42
|
+
export const KEYCHAIN_DENY_PROFILE = '(version 1)(allow default)(deny mach-lookup (global-name "com.apple.SecurityServer") '
|
|
43
|
+
+ '(global-name "com.apple.securityd.xpc") (global-name "com.apple.security.agent") (global-name "com.apple.secd"))';
|
|
44
|
+
const SANDBOX_EXEC = '/usr/bin/sandbox-exec';
|
|
45
|
+
/** [command, args] that start claude without keychain access on macOS (the real spawn only; an injected spawn is not wrapped). */
|
|
46
|
+
export function claudeCommand(claudeBin, args, { platform = process.platform, exists = fs.existsSync } = {}) {
|
|
47
|
+
return platform === 'darwin' && exists(SANDBOX_EXEC) ? [SANDBOX_EXEC, ['-p', KEYCHAIN_DENY_PROFILE, claudeBin, ...args]] : [claudeBin, args];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function sanitizedEnv(baseEnv, { ghConfigDir }) {
|
|
51
|
+
const env = {};
|
|
52
|
+
for (const [k, v] of Object.entries(baseEnv || {})) {
|
|
53
|
+
if (STRIPPED_ENV.some((re) => re.test(k)) || /^GIT_CONFIG_/.test(k)) continue;
|
|
54
|
+
env[k] = v;
|
|
55
|
+
}
|
|
56
|
+
return { ...env, GH_CONFIG_DIR: ghConfigDir, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '/usr/bin/false',
|
|
57
|
+
GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'credential.helper', GIT_CONFIG_VALUE_0: '' };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const stageOf = (stage) => { const c = STAGE_CONFIG[stage]; if (!c) throw new Error(`unknown stage: ${stage}`); return c; };
|
|
61
|
+
|
|
62
|
+
export function buildClaudeArgs(stage, prompt, { root }) {
|
|
63
|
+
const c = stageOf(stage);
|
|
64
|
+
const schema = c.schema ? ['--json-schema', JSON.stringify(JSON.parse(fs.readFileSync(path.join(root, c.schema), 'utf8')))] : [];
|
|
65
|
+
// --restricted: no code-running tool, user/project/local settings ignored (so a hook or permission the build stage
|
|
66
|
+
// writes into the worktree never loads), file tools confined to the cwd, writes to settings/git files refused.
|
|
67
|
+
// --safe-mode: no CLAUDE.md, skills, plugins, hooks or MCP from anywhere. Both measured live on claude 2.1.314
|
|
68
|
+
// (2026-10-30): planted .claude/settings.json hook and CLAUDE.md inert; reads/writes outside the cwd refused.
|
|
69
|
+
// --permission-prompts none: anything that would ask is denied. Subscription OAuth still works (no API key used).
|
|
70
|
+
const tools = c.tools.length ? c.tools.join(',') : '';
|
|
71
|
+
const allow = c.tools.length ? ['--allowedTools', ...c.tools] : [];
|
|
72
|
+
return ['-p', prompt, '--model', c.model, '--max-turns', String(c.maxTurns), '--output-format', 'json', ...schema,
|
|
73
|
+
'--restricted', '--safe-mode', '--tools', tools, ...allow, '--disallowedTools', 'Bash', 'WebFetch', 'WebSearch', 'Task',
|
|
74
|
+
'--permission-prompts', 'none', '--strict-mcp-config', '--mcp-config', path.join(root, NO_MCP),
|
|
75
|
+
'--no-session-persistence', '--max-budget-usd', '5'];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export { buildPrompt } from './issue-autofix-prompts.mjs';
|
|
79
|
+
import { buildPrompt } from './issue-autofix-prompts.mjs';
|
|
80
|
+
|
|
81
|
+
// ── minimal JSON-schema check (type/enum/const/required/additionalProperties/items/min/max). The claude CLI
|
|
82
|
+
// validates too; this keeps a hallucinated shape from ever reaching the policy code. ──
|
|
83
|
+
function check(schema, v, at, errs) {
|
|
84
|
+
if ('const' in schema && v !== schema.const) errs.push(`${at}: must be ${JSON.stringify(schema.const)}`);
|
|
85
|
+
if (schema.enum && !schema.enum.includes(v)) errs.push(`${at}: not one of ${schema.enum.join('|')}`);
|
|
86
|
+
const t = schema.type;
|
|
87
|
+
const actual = Array.isArray(v) ? 'array' : v === null ? 'null' : Number.isInteger(v) ? 'integer' : typeof v;
|
|
88
|
+
if (t && !(t === actual || (t === 'number' && actual === 'integer'))) { errs.push(`${at}: expected ${t}, got ${actual}`); return; }
|
|
89
|
+
if (t === 'string') {
|
|
90
|
+
if (schema.maxLength != null && v.length > schema.maxLength) errs.push(`${at}: longer than ${schema.maxLength}`);
|
|
91
|
+
if (schema.pattern && !new RegExp(schema.pattern).test(v)) errs.push(`${at}: does not match ${schema.pattern}`);
|
|
92
|
+
}
|
|
93
|
+
if (t === 'integer' && schema.minimum != null && v < schema.minimum) errs.push(`${at}: below ${schema.minimum}`);
|
|
94
|
+
if (t === 'array') {
|
|
95
|
+
if (schema.minItems != null && v.length < schema.minItems) errs.push(`${at}: fewer than ${schema.minItems} items`);
|
|
96
|
+
if (schema.maxItems != null && v.length > schema.maxItems) errs.push(`${at}: more than ${schema.maxItems} items`);
|
|
97
|
+
if (schema.items) v.forEach((x, i) => check(schema.items, x, `${at}[${i}]`, errs));
|
|
98
|
+
}
|
|
99
|
+
if (t === 'object') {
|
|
100
|
+
for (const k of schema.required || []) if (!(k in v)) errs.push(`${at}.${k}: missing`);
|
|
101
|
+
for (const [k, x] of Object.entries(v)) {
|
|
102
|
+
if (schema.properties?.[k]) check(schema.properties[k], x, `${at}.${k}`, errs);
|
|
103
|
+
else if (schema.additionalProperties === false) errs.push(`${at}.${k}: not allowed`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// The model that served the call: the key of `modelUsage` that cost the most (helper calls to a smaller model do
|
|
109
|
+
// not count), else the `model` field.
|
|
110
|
+
function resolveModel(out) {
|
|
111
|
+
const usage = out?.modelUsage && typeof out.modelUsage === 'object' ? Object.entries(out.modelUsage) : [];
|
|
112
|
+
if (usage.length) return usage.sort((a, b) => (Number(b[1]?.costUSD) || 0) - (Number(a[1]?.costUSD) || 0))[0][0];
|
|
113
|
+
return typeof out?.model === 'string' && out.model ? out.model : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function assertModelFamily(stage, resolvedModel) {
|
|
117
|
+
const c = stageOf(stage);
|
|
118
|
+
if (!resolvedModel || !c.family.test(resolvedModel)) {
|
|
119
|
+
throw new AutofixAbort(stage, 'model-downgrade', `asked for ${c.model}; ${resolvedModel ? `served by ${resolvedModel}` : 'the result names no model'}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function runAgent({ stage, cwd, input, claudeBin, logPath, root, ghConfigDir, timeoutMs, graceMs, spawnImpl = spawn }) {
|
|
124
|
+
const c = stageOf(stage);
|
|
125
|
+
const limit = timeoutMs ?? STAGE_CONFIG[stage].timeoutMs;
|
|
126
|
+
const grace = graceMs ?? GRACE_MS;
|
|
127
|
+
const t0 = Date.now();
|
|
128
|
+
const ownDir = ghConfigDir ? null : fs.mkdtempSync(path.join(os.tmpdir(), 'autofix-gh-'));
|
|
129
|
+
const finish = (r) => ({ ok: false, stage, exitCode: null, signal: null, timedOut: false, resolvedModel: null, result: null, error: null, logPath, ...r, durationMs: Date.now() - t0 });
|
|
130
|
+
try {
|
|
131
|
+
// Behind sandbox-exec a missing binary would only show as an exit code: say what is wrong plainly.
|
|
132
|
+
if (path.isAbsolute(claudeBin) && !fs.existsSync(claudeBin)) return finish({ error: `spawn failed: ENOENT ${claudeBin}` });
|
|
133
|
+
const args = buildClaudeArgs(stage, buildPrompt(stage, input), { root });
|
|
134
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
135
|
+
const logFd = fs.openSync(logPath, 'a');
|
|
136
|
+
fs.writeSync(logFd, `===== autofix ${stage} — started ${new Date().toISOString()} (model ${c.model}, max-turns ${c.maxTurns}, timeout ${limit}ms) =====\n`);
|
|
137
|
+
const env = sanitizedEnv(process.env, { ghConfigDir: ghConfigDir || ownDir });
|
|
138
|
+
const res = await new Promise((resolve) => {
|
|
139
|
+
let stdout = ''; let timedOut = false; let killTimer; let graceTimer; let child;
|
|
140
|
+
const [cmd, argv] = spawnImpl === spawn ? claudeCommand(claudeBin, args) : [claudeBin, args];
|
|
141
|
+
try { child = spawnImpl(cmd, argv, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] }); } catch (e) { resolve({ spawnError: e }); return; }
|
|
142
|
+
child.on('error', (e) => { clearTimeout(killTimer); clearTimeout(graceTimer); resolve({ spawnError: e }); });
|
|
143
|
+
child.stdout.on('data', (d) => { if (stdout.length < MAX_STDOUT) stdout += d; fs.writeSync(logFd, d); });
|
|
144
|
+
child.stderr.on('data', (d) => fs.writeSync(logFd, d));
|
|
145
|
+
killTimer = setTimeout(() => {
|
|
146
|
+
timedOut = true;
|
|
147
|
+
fs.writeSync(logFd, `\n===== WALL-CLOCK TIMEOUT (${limit}ms) — SIGTERM =====\n`);
|
|
148
|
+
child.kill('SIGTERM');
|
|
149
|
+
graceTimer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* already dead */ } }, grace);
|
|
150
|
+
}, limit);
|
|
151
|
+
child.on('close', (code, signal) => { clearTimeout(killTimer); clearTimeout(graceTimer); resolve({ code, signal, timedOut, stdout }); });
|
|
152
|
+
});
|
|
153
|
+
try { fs.writeSync(logFd, `\n===== autofix ${stage} — ended ${new Date().toISOString()} =====\n`); fs.closeSync(logFd); } catch { /* log only */ }
|
|
154
|
+
if (res.spawnError) return finish({ error: `spawn failed: ${res.spawnError.message}` });
|
|
155
|
+
const base = { exitCode: res.code, signal: res.signal, timedOut: res.timedOut };
|
|
156
|
+
if (res.timedOut) return finish({ ...base, error: `${stage} timed out after ${limit}ms` });
|
|
157
|
+
let out;
|
|
158
|
+
try { out = JSON.parse(res.stdout); } catch { out = null; }
|
|
159
|
+
const resolvedModel = resolveModel(out);
|
|
160
|
+
if (res.code !== 0) return finish({ ...base, resolvedModel, error: `claude exited with exit ${res.code}${res.signal ? ` (${res.signal})` : ''}` });
|
|
161
|
+
if (!out || typeof out !== 'object') return finish({ ...base, error: 'claude output was not JSON' });
|
|
162
|
+
if (out.is_error || (out.subtype && out.subtype !== 'success')) return finish({ ...base, resolvedModel, error: `claude reported ${out.subtype || 'error'}` });
|
|
163
|
+
assertModelFamily(stage, resolvedModel); // fail closed: never accept work from a model we did not ask for
|
|
164
|
+
if (!c.schema) return finish({ ...base, ok: true, resolvedModel });
|
|
165
|
+
let result = out.structured_output;
|
|
166
|
+
if (result === undefined && typeof out.result === 'string') { try { result = JSON.parse(out.result); } catch { /* handled below */ } }
|
|
167
|
+
if (!result || typeof result !== 'object') return finish({ ...base, resolvedModel, error: 'no structured output in the result' });
|
|
168
|
+
const errs = []; check(JSON.parse(fs.readFileSync(path.join(root, c.schema), 'utf8')), result, '$', errs);
|
|
169
|
+
if (errs.length) return finish({ ...base, resolvedModel, error: `structured output failed the schema: ${errs.slice(0, 5).join('; ')}` });
|
|
170
|
+
return finish({ ...base, ok: true, resolvedModel, result });
|
|
171
|
+
} finally { if (ownDir) fs.rmSync(ownDir, { recursive: true, force: true }); }
|
|
172
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// Issue autofix REPORTER COMMENTS (ADR-107 §6). What a person who filed an issue reads from the automation.
|
|
2
|
+
//
|
|
3
|
+
// Four promises, each enforced in code rather than hoped for:
|
|
4
|
+
// 1. MARKER FIRST. Every body begins with BOT_MARKER. issue-watch's judgeIssue ignores an owner comment only when it
|
|
5
|
+
// starts with the marker; a body without it would count as the maintainer's own reply and silence the SLA page.
|
|
6
|
+
// 2. HONEST. `fixed` is the only kind that says "Fixed in"; `shipping` says it is still being verified; `findings`
|
|
7
|
+
// claims no fix. A hidden marker at the END makes every outcome idempotent even if the state file is wiped.
|
|
8
|
+
// 3. PERSONAL. The thanks sentence must name something concrete from THEIR issue and must not echo a note we already
|
|
9
|
+
// sent that person; otherwise it is dropped (the caller pages the owner to write one) rather than posted generic.
|
|
10
|
+
// 4. SAFE. The note fields are model-written. They are scrubbed of closing keywords, other @mentions, robot markers and
|
|
11
|
+
// HTML comments before they can reach a public thread.
|
|
12
|
+
import { BOT_MARKER, OWNER_LOGIN } from './issue-watch.mjs';
|
|
13
|
+
import { AutofixAbort, findClosingKeywords, CLOSING_KEYWORD_SOURCE } from './issue-autofix-policy.mjs';
|
|
14
|
+
|
|
15
|
+
export { BOT_MARKER, OWNER_LOGIN };
|
|
16
|
+
|
|
17
|
+
const OUTCOMES = new Set(['fixed', 'shipping', 'findings', 'not-fixed', 'reply']);
|
|
18
|
+
const MARKER_RE = /<!--\s*ruvnet-autofix v1\s+([^>]*?)\s*-->/g;
|
|
19
|
+
// Our own comment (and the autofix:needs-owner label that follows it) bumps issue.updatedAt a moment after the marker's
|
|
20
|
+
// `at`. Only an update later than this grace counts as the reporter coming back.
|
|
21
|
+
export const FINDINGS_GRACE_MS = 10 * 60_000;
|
|
22
|
+
export const SIMILARITY_LIMIT = 0.5;
|
|
23
|
+
|
|
24
|
+
const fail = (detail) => new AutofixAbort('S13', 'comment-failed', detail);
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------------------------
|
|
27
|
+
// Hidden marker
|
|
28
|
+
// ---------------------------------------------------------------------------------------------
|
|
29
|
+
export function hiddenMarker({ issueNumber, outcome, version, sha, at } = {}) {
|
|
30
|
+
if (!Number.isInteger(issueNumber) || issueNumber < 1) throw fail('hiddenMarker: bad issue number');
|
|
31
|
+
if (!OUTCOMES.has(outcome)) throw fail(`hiddenMarker: bad outcome ${outcome}`);
|
|
32
|
+
const parts = [`issue=${issueNumber}`, `outcome=${outcome}`];
|
|
33
|
+
if (version != null) { if (!/^\d+\.\d+\.\d+$/.test(version)) throw fail('hiddenMarker: bad version'); parts.push(`version=${version}`); }
|
|
34
|
+
if (sha != null) { if (!/^[0-9a-f]{7,40}$/.test(sha)) throw fail('hiddenMarker: bad sha'); parts.push(`sha=${sha}`); }
|
|
35
|
+
if (at != null) { if (!/^[\w:.+-]+$/.test(at)) throw fail('hiddenMarker: bad timestamp'); parts.push(`at=${at}`); }
|
|
36
|
+
return `<!-- ruvnet-autofix v1 ${parts.join(' ')} -->`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const isBotComment = (c) => String(c?.author?.login || '').toLowerCase() === OWNER_LOGIN
|
|
40
|
+
&& String(c?.body || '').trimStart().startsWith(BOT_MARKER);
|
|
41
|
+
|
|
42
|
+
function markerEntries(comments) {
|
|
43
|
+
const out = [];
|
|
44
|
+
for (const c of Array.isArray(comments) ? comments : []) {
|
|
45
|
+
if (!isBotComment(c)) continue; // a stranger quoting a marker proves nothing
|
|
46
|
+
for (const m of String(c.body).matchAll(MARKER_RE)) {
|
|
47
|
+
const kv = Object.fromEntries([...m[1].matchAll(/(\w+)=(\S+)/g)].map((x) => [x[1], x[2]]));
|
|
48
|
+
const issue = Number(kv.issue);
|
|
49
|
+
if (!Number.isInteger(issue) || !kv.outcome) continue;
|
|
50
|
+
out.push({ marker: { issue, outcome: kv.outcome, version: kv.version ?? null, sha: kv.sha ?? null, at: kv.at ?? null }, createdAt: c.createdAt });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const parseHiddenMarkers = (comments) => markerEntries(comments).map((e) => e.marker);
|
|
57
|
+
|
|
58
|
+
export function alreadyPosted(comments, { outcome, version, issueUpdatedAt } = {}) {
|
|
59
|
+
const entries = markerEntries(comments);
|
|
60
|
+
if (outcome === 'reply') return entries.some(({ marker: m }) => m.outcome === 'reply'); // once per issue, ever
|
|
61
|
+
if (outcome === 'findings' || outcome === 'not-fixed') {
|
|
62
|
+
const times = entries.filter((e) => e.marker.outcome === outcome)
|
|
63
|
+
.map((e) => Math.max(Date.parse(e.marker.at) || 0, Date.parse(e.createdAt) || 0));
|
|
64
|
+
if (times.length === 0) return false;
|
|
65
|
+
const updated = Date.parse(issueUpdatedAt);
|
|
66
|
+
if (!Number.isFinite(updated)) return true; // cannot show the reporter came back: do not repeat ourselves
|
|
67
|
+
return !(updated > Math.max(...times) + FINDINGS_GRACE_MS);
|
|
68
|
+
}
|
|
69
|
+
return entries.some(({ marker: m }) => m.version === (version ?? null)
|
|
70
|
+
&& (m.outcome === outcome || (outcome === 'shipping' && m.outcome === 'fixed'))); // "shipping" after "fixed" would be a regression
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------------------------
|
|
74
|
+
// Thanks: specific and never repeated
|
|
75
|
+
// ---------------------------------------------------------------------------------------------
|
|
76
|
+
const trigrams = (s) => {
|
|
77
|
+
const t = String(s ?? '').toLowerCase().replace(/\s+/g, ' ').trim();
|
|
78
|
+
const set = new Set();
|
|
79
|
+
for (let i = 0; i + 3 <= t.length; i++) set.add(t.slice(i, i + 3));
|
|
80
|
+
return set;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export function trigramSimilarity(a, b) {
|
|
84
|
+
const x = trigrams(a); const y = trigrams(b);
|
|
85
|
+
if (x.size === 0 && y.size === 0) return 0;
|
|
86
|
+
let both = 0;
|
|
87
|
+
for (const g of x) if (y.has(g)) both++;
|
|
88
|
+
return both / (x.size + y.size - both);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const STOP = new Set(['the', 'a', 'an', 'this', 'that', 'issue', 'bug', 'report', 'problem', 'thanks', 'thank', 'you', 'for', 'your', 'it', 'is', 'of', 'to', 'and', 'in', 'on', 'with', 'error', 'fix', 'was', 'were', 'when', 'what']);
|
|
92
|
+
const codeLike = (t) => /[./\\_:()[\]{}=<>\d]/.test(t) || /[a-z][A-Z]/.test(t) || /^--?[a-z]/i.test(t)
|
|
93
|
+
|| (/^[A-Z][A-Z0-9_]{3,}$/.test(t) && !STOP.has(t.toLowerCase())); // error codes such as ENOENT
|
|
94
|
+
|
|
95
|
+
/** Concrete things a sentence points at: code-shaped tokens (path, function, flag, version) or a quoted phrase that is not
|
|
96
|
+
* just filler. Each must appear VERBATIM in the reporter's own text. */
|
|
97
|
+
function specificMentions(thanks, issueText) {
|
|
98
|
+
const found = [];
|
|
99
|
+
for (const raw of String(thanks).split(/\s+/)) {
|
|
100
|
+
const tok = raw.replace(/^[("'`*_]+/, '').replace(/[)"'`*_.,;:!?]+$/, '');
|
|
101
|
+
if (tok.replace(/[^A-Za-z0-9]/g, '').length >= 4 && codeLike(tok) && issueText.includes(tok)) found.push(tok);
|
|
102
|
+
}
|
|
103
|
+
for (const m of String(thanks).matchAll(/[`"“]([^`"”]{4,120})[`"”]/g)) {
|
|
104
|
+
const span = m[1].trim();
|
|
105
|
+
const words = span.toLowerCase().split(/\W+/).filter(Boolean);
|
|
106
|
+
if (span.length >= 4 && words.some((w) => !STOP.has(w)) && issueText.includes(span)) found.push(span);
|
|
107
|
+
}
|
|
108
|
+
return found;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function checkThanks({ thanks, issueText, priorNotes = [] } = {}) {
|
|
112
|
+
const text = String(thanks ?? '').trim();
|
|
113
|
+
if (!text) return { ok: false, reasons: ['empty'] };
|
|
114
|
+
const reasons = [];
|
|
115
|
+
if (specificMentions(text, String(issueText ?? '')).length === 0) reasons.push('not-specific');
|
|
116
|
+
if ((priorNotes || []).some((p) => trigramSimilarity(text, p) >= SIMILARITY_LIMIT)) reasons.push('too-similar');
|
|
117
|
+
return { ok: reasons.length === 0, reasons };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------------------------
|
|
121
|
+
// Rendering
|
|
122
|
+
// ---------------------------------------------------------------------------------------------
|
|
123
|
+
const NAMES = { windows: 'Windows', linux: 'Linux', macos: 'macOS', codex: 'Codex', grok: 'Grok' };
|
|
124
|
+
const ran = new Set(['macos', 'linux']); // the platforms our evidence actually ran on
|
|
125
|
+
|
|
126
|
+
/** Scrub model-written text for a public thread (see header, promise 4). */
|
|
127
|
+
function scrub(text, kind, max) {
|
|
128
|
+
let t = String(text ?? '')
|
|
129
|
+
.replace(/<!--[\s\S]*?-->/g, ' ').replace(/<!--|-->/g, ' ')
|
|
130
|
+
.split(BOT_MARKER).join('')
|
|
131
|
+
.replace(/[\u0000-\u001f\u007f]+/g, ' ')
|
|
132
|
+
.replace(/(^|[^\w.])@(?=[A-Za-z0-9_-])/g, '$1')
|
|
133
|
+
.replace(new RegExp(CLOSING_KEYWORD_SOURCE, 'gi'), (m) => `see ${m.replace(/^\w+\s*:?\s*/, '')}`)
|
|
134
|
+
.replace(/\s+/g, ' ').trim();
|
|
135
|
+
if (kind !== 'fixed') t = t.replace(/\bfixed in\b/gi, 'changed in');
|
|
136
|
+
return t.length > max ? `${t.slice(0, max - 1).trimEnd()}…` : t;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const joinNames = (xs) => (xs.length <= 1 ? xs.join('') : `${xs.slice(0, -1).join(', ')} and ${xs[xs.length - 1]}`);
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* thanks: the person's note (only on the first of their issues tonight) or null; thanksOn: the issue that carries it.
|
|
143
|
+
* entry: the note's perIssue entry for THIS issue; facts: {verified, notVerified} measured by machines, never by a model.
|
|
144
|
+
*/
|
|
145
|
+
export function renderComment({ kind, issueNumber, login, thanks = null, thanksOn = null, entry = {}, facts = null, version, sha, commitUrl, testPath, platformCaveat = [], at } = {}) {
|
|
146
|
+
if (!OUTCOMES.has(kind)) throw fail(`renderComment: unknown kind ${kind}`);
|
|
147
|
+
if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(String(login || ''))) throw fail('renderComment: unsafe reporter login');
|
|
148
|
+
const t = thanks ? scrub(thanks, kind, 700) : '';
|
|
149
|
+
const hi = t ? `Hi @${login} — ${t}` : Number.isInteger(thanksOn) && thanksOn !== issueNumber ? `Hi @${login} — my note to you is on issue ${thanksOn}.` : `Hi @${login}.`;
|
|
150
|
+
const wrong = scrub(entry.whatWasWrong, kind, 600);
|
|
151
|
+
if (!wrong) throw fail('renderComment: entry.whatWasWrong is required');
|
|
152
|
+
const list = (xs) => (xs || []).map((x) => scrub(x, kind, 200)).filter(Boolean).join('; ');
|
|
153
|
+
const factsLine = facts && (facts.verified?.length || facts.notVerified?.length)
|
|
154
|
+
? [`**Checked:** ${list(facts.verified) || 'nothing yet'}.`, facts.notVerified?.length ? ` **Not checked:** ${list(facts.notVerified)}.` : ''].join('') : null;
|
|
155
|
+
const needs = scrub(entry.whatItNeeds, kind, 600) || 'a maintainer’s decision.';
|
|
156
|
+
const foot = (o) => hiddenMarker({ issueNumber, outcome: o, at: at ?? new Date().toISOString() });
|
|
157
|
+
|
|
158
|
+
if (kind === 'findings') {
|
|
159
|
+
return [`${BOT_MARKER} findings — RuvNet Brain`, '', hi, '',
|
|
160
|
+
`I dug into this rather than guess at a patch. Here's what I found: ${wrong}`,
|
|
161
|
+
`What it needs: ${needs} I've flagged it for the maintainer, so the next reply here will be from a person or a real fix.`, '', foot('findings')].join('\n');
|
|
162
|
+
}
|
|
163
|
+
if (kind === 'not-fixed') {
|
|
164
|
+
return [`${BOT_MARKER} status — RuvNet Brain`, '', hi, '',
|
|
165
|
+
`I worked on a fix for this, but it did not pass verification, so nothing was shipped. What I found: ${wrong}`,
|
|
166
|
+
...(factsLine ? [factsLine] : []),
|
|
167
|
+
`What it needs: ${needs} A maintainer will take it from here.`, '', foot('not-fixed')].join('\n');
|
|
168
|
+
}
|
|
169
|
+
if (kind === 'reply') {
|
|
170
|
+
return [`${BOT_MARKER} note — RuvNet Brain`, '', hi, '',
|
|
171
|
+
`${wrong}`, `This needs the maintainer's call, so nothing has been changed: ${needs} I've flagged it, so the next reply here will be from a person.`, '', foot('reply')].join('\n');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const changed = scrub(entry.whatChanged, kind, 400);
|
|
175
|
+
if (!changed) throw fail('renderComment: entry.whatChanged is required');
|
|
176
|
+
if (!/^\d+\.\d+\.\d+$/.test(String(version))) throw fail('renderComment: version is required');
|
|
177
|
+
if (!/^[0-9a-f]{7,40}$/.test(String(sha))) throw fail('renderComment: sha is required');
|
|
178
|
+
const short = sha.slice(0, 7);
|
|
179
|
+
const ref = /^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/commit\/[0-9a-f]{7,40}$/.test(String(commitUrl)) ? `[${short}](${commitUrl})` : `\`${short}\``;
|
|
180
|
+
const test = String(testPath || '').replace(/[`\s]/g, '');
|
|
181
|
+
const where = kind === 'fixed'
|
|
182
|
+
? `Fixed in **v${version}**, published to npm and GitHub and install-verified on Linux, macOS and Windows. \`npx ruvnet-brain@latest --update\` picks it up (plugin installs update on their own).`
|
|
183
|
+
: `Built and merged; shipping in **v${version}**, which is still finishing verification. I'll confirm here once it is verified on all three platforms.`;
|
|
184
|
+
const missing = [...new Set((platformCaveat || []).map((p) => String(p).toLowerCase()))].filter((p) => !ran.has(p) && NAMES[p]);
|
|
185
|
+
const lines = [`${BOT_MARKER} fix report — RuvNet Brain`, '', hi, '',
|
|
186
|
+
`**What was wrong:** ${wrong}`,
|
|
187
|
+
`**What changed:** ${ref} — ${changed}${test ? ` It comes with a new test (\`${test}\`) that fails on the old code and passes now.` : ''}`,
|
|
188
|
+
`**Where it is:** ${where}`];
|
|
189
|
+
if (factsLine) lines.push(factsLine);
|
|
190
|
+
if (missing.length) lines.push(`Our tests ran on macOS and Linux; if you can confirm on ${joinNames(missing.map((p) => NAMES[p]))}, that closes the loop.`);
|
|
191
|
+
lines.push('If it still looks wrong after updating, reply here or open a new issue — a maintainer sees both.', '',
|
|
192
|
+
hiddenMarker({ issueNumber, outcome: kind, version, sha }));
|
|
193
|
+
return lines.join('\n');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------------------------
|
|
197
|
+
// Posting (the only function here that writes to GitHub)
|
|
198
|
+
// ---------------------------------------------------------------------------------------------
|
|
199
|
+
export function postOutcome(ctx, { issueNumber, body, close = false, addLabels = [] } = {}) {
|
|
200
|
+
if (!Number.isInteger(issueNumber) || issueNumber < 1) throw fail('postOutcome: bad issue number');
|
|
201
|
+
if (typeof body !== 'string' || !body.startsWith(BOT_MARKER)) throw fail('body must begin with the bot marker');
|
|
202
|
+
if (body.startsWith(`${BOT_MARKER} acknowledgment`)) throw fail('body must not impersonate the acknowledgment');
|
|
203
|
+
if (findClosingKeywords(body).length) throw fail('body contains a closing keyword');
|
|
204
|
+
const own = markerEntries([{ author: { login: OWNER_LOGIN }, body }]).map((e) => e.marker);
|
|
205
|
+
if (own.length === 0) throw fail('body lacks the hidden dedupe marker');
|
|
206
|
+
if (own.some((m) => m.issue !== issueNumber)) throw fail('hidden marker names a different issue');
|
|
207
|
+
if (close && own[own.length - 1].outcome !== 'fixed') throw fail('only a fixed comment may close an issue');
|
|
208
|
+
if (!(addLabels || []).every((l) => /^autofix:[a-z-]+$/.test(l))) throw fail('label outside the autofix: namespace');
|
|
209
|
+
if (!/^[\w.-]+\/[\w.-]+$/.test(String(ctx?.repo))) throw fail('bad repo');
|
|
210
|
+
|
|
211
|
+
const gh = ctx.ghBin || process.env.GH_BIN || 'gh';
|
|
212
|
+
const base = (...a) => [...a, String(issueNumber), '--repo', ctx.repo];
|
|
213
|
+
const result = { commented: false, closed: false, labeled: false, error: null };
|
|
214
|
+
const step = (args) => {
|
|
215
|
+
try { const r = ctx.exec(gh, args); return r.status === 0 ? null : String(r.stderr || r.stdout || `exit ${r.status}`).trim().slice(0, 300); }
|
|
216
|
+
catch (e) { if (e?.name === 'AutofixAbort') throw e; return String(e?.message || e).slice(0, 300); }
|
|
217
|
+
};
|
|
218
|
+
let err = step([...base('issue', 'comment'), '--body', body]);
|
|
219
|
+
if (err) return { ...result, error: `comment: ${err}` };
|
|
220
|
+
result.commented = true;
|
|
221
|
+
if (addLabels.length) { err = step([...base('issue', 'edit'), '--add-label', addLabels.join(',')]); if (err) result.error = `label: ${err}`; else result.labeled = true; }
|
|
222
|
+
if (close) { err = step([...base('issue', 'close'), '--reason', 'completed']); if (err) result.error = [result.error, `close: ${err}`].filter(Boolean).join('; '); else result.closed = true; }
|
|
223
|
+
return result;
|
|
224
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// scripts/issue-autofix-diff.mjs — what the build stage changed, and whether its new test is a real test.
|
|
2
|
+
// The orchestrator never takes the builder's word for either: it reads the diff from git itself (S4) and measures
|
|
3
|
+
// the new test against origin/main WITHOUT the fix and against the worktree WITH it (spec §4 "Test validity").
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { AutofixAbort, judgeDiff, judgeTestValidity } from './issue-autofix-policy.mjs';
|
|
7
|
+
import { parseUnifiedDiff } from './issue-autofix-release-util.mjs';
|
|
8
|
+
|
|
9
|
+
const GIT_DIFF = ['diff', '--no-color', '--no-ext-diff', '--no-renames'];
|
|
10
|
+
const SAFE_TEST_PATH = /^[\w./-]+$/;
|
|
11
|
+
|
|
12
|
+
/** The working-tree diff against the commit the worktree was cut from (untracked files included via intent-to-add). */
|
|
13
|
+
export function collectDiff(exec, wt, gitBin = 'git') {
|
|
14
|
+
const g = (...a) => exec(gitBin, ['-C', wt, ...a]);
|
|
15
|
+
const head = g('rev-parse', 'HEAD');
|
|
16
|
+
if (head.status !== 0) throw new AutofixAbort('S4', 'diff-policy', 'cannot read the worktree HEAD');
|
|
17
|
+
const add = g('add', '-A', '-N');
|
|
18
|
+
if (add.status !== 0) throw new AutofixAbort('S4', 'diff-policy', `git add -N failed: ${add.stderr.slice(-300)}`);
|
|
19
|
+
const u0 = g(...GIT_DIFF, '-U0', 'HEAD');
|
|
20
|
+
const full = g(...GIT_DIFF, 'HEAD');
|
|
21
|
+
if (u0.status !== 0 || full.status !== 0) throw new AutofixAbort('S4', 'diff-policy', 'git diff failed');
|
|
22
|
+
const files = parseUnifiedDiff(u0.stdout);
|
|
23
|
+
return { base: head.stdout.trim(), files, text: full.stdout,
|
|
24
|
+
changedLines: files.reduce((n, f) => n + f.added.length + f.removed.length, 0) };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** judgeDiff, mapped to the abort code the receipt and page use. */
|
|
28
|
+
export function assertDiffAllowed(diff, governed0103) {
|
|
29
|
+
if (diff.files.length === 0) throw new AutofixAbort('S4', 'diff-policy', 'the build stage changed nothing');
|
|
30
|
+
const verdict = judgeDiff({ files: diff.files, governed0103 });
|
|
31
|
+
if (!verdict.ok) {
|
|
32
|
+
const v = verdict.violations[0];
|
|
33
|
+
const code = { 'test-weakened': 'test-weakened', 'protected-path': 'protected-path', 'not-editable': 'not-editable' }[v.rule] || 'diff-policy';
|
|
34
|
+
throw new AutofixAbort('S4', code, verdict.violations.map((x) => `${x.rule} ${x.path}: ${x.detail}`).join('; ').slice(0, 800));
|
|
35
|
+
}
|
|
36
|
+
return verdict;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseVitestJson(stdout) {
|
|
40
|
+
const i = stdout.indexOf('{'); const j = stdout.lastIndexOf('}');
|
|
41
|
+
if (i < 0 || j <= i) return null;
|
|
42
|
+
try { return JSON.parse(stdout.slice(i, j + 1)); } catch { return null; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* `vitest run <files>` in `cwd`, ALWAYS through the sandbox runner (issue-autofix-sandbox.mjs): the files were written
|
|
47
|
+
* by a model from a stranger's issue. vitest is started from the worktree's own node_modules entry, never through npx,
|
|
48
|
+
* so nothing is resolved from the network. Returns the exit code, the failing tests ("<file> > <title>") and the output.
|
|
49
|
+
*/
|
|
50
|
+
export function runVitest(runSandboxed, cwd, files) {
|
|
51
|
+
if (typeof runSandboxed !== 'function') throw new AutofixAbort('S4', 'sandbox-unavailable', 'no sandbox runner');
|
|
52
|
+
if (!files.length || !files.every((f) => SAFE_TEST_PATH.test(f) && !f.startsWith('-') && !f.split('/').includes('..'))) {
|
|
53
|
+
throw new AutofixAbort('S4', 'test-validity', `unsafe test path in ${JSON.stringify(files)}`);
|
|
54
|
+
}
|
|
55
|
+
const r = runSandboxed(process.execPath, ['node_modules/vitest/vitest.mjs', 'run', '--reporter=json', ...files], { cwd, timeoutMs: 20 * 60_000 });
|
|
56
|
+
const out = `${r.stdout}${r.stderr}`;
|
|
57
|
+
const json = parseVitestJson(r.stdout);
|
|
58
|
+
const failedTests = [];
|
|
59
|
+
for (const tr of json?.testResults || []) {
|
|
60
|
+
const file = files.find((f) => String(tr.name).endsWith(`/${f}`) || tr.name === f) || tr.name;
|
|
61
|
+
const failed = (tr.assertionResults || []).filter((a) => a.status === 'failed');
|
|
62
|
+
for (const a of failed) failedTests.push(`${file} > ${a.fullName || a.title}`);
|
|
63
|
+
// A new test that imports what the fix adds cannot even load against the old code: the FILE failed. That is the
|
|
64
|
+
// new test failing on unfixed code, attributed to that file (judgeTestValidity still requires it to be a new test).
|
|
65
|
+
if (tr.status === 'failed' && failed.length === 0) failedTests.push(`${file} > (the test file failed to load)`);
|
|
66
|
+
}
|
|
67
|
+
return { exitCode: r.timedOut ? 124 : r.status, failedTests, output: out.slice(-6000) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The worktree gets its OWN node_modules directory whose entries link to the shared install (measured 2026-10-30:
|
|
72
|
+
* vitest compiles vitest.config.mjs into node_modules/.vite-temp, and with node_modules itself a link into the shared
|
|
73
|
+
* install the sandbox refused that write, so no real test could start). Packages still resolve to the shared, read-only
|
|
74
|
+
* install; vitest's scratch files land inside the worktree, where the sandbox allows writes.
|
|
75
|
+
*/
|
|
76
|
+
const linkModules = (from, to) => {
|
|
77
|
+
const dest = path.join(to, 'node_modules');
|
|
78
|
+
if (!from || !fs.existsSync(from) || fs.existsSync(dest)) return;
|
|
79
|
+
const src = fs.realpathSync(from);
|
|
80
|
+
fs.mkdirSync(dest);
|
|
81
|
+
for (const e of fs.readdirSync(src)) {
|
|
82
|
+
if (e === '.vite' || e === '.vite-temp' || e === '.cache') continue; // tool scratch stays per worktree
|
|
83
|
+
fs.symlinkSync(path.join(src, e), path.join(dest, e));
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
export { linkModules };
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The new tests must FAIL on unfixed code and PASS on the fix. `scratch` is a detached worktree at the base commit
|
|
90
|
+
* onto which only the test files are copied.
|
|
91
|
+
*/
|
|
92
|
+
export function measureTestValidity(exec, { wt, scratch, diff, newTests, nodeModules, gitBin = 'git', runSandboxed }) {
|
|
93
|
+
const present = newTests.map((t) => t.replace(/^(\.\/)+/, '')).filter((t) => diff.files.some((f) => f.path === t));
|
|
94
|
+
if (present.length === 0) return { verdict: judgeTestValidity({ newTests: [] }), before: null, after: null, newTests: [] };
|
|
95
|
+
const head = exec(gitBin, ['-C', wt, 'rev-parse', 'HEAD']);
|
|
96
|
+
const mk = exec(gitBin, ['-C', wt, 'worktree', 'add', '--detach', scratch, head.stdout.trim()]);
|
|
97
|
+
if (mk.status !== 0) throw new AutofixAbort('S4', 'test-validity', `cannot create the scratch worktree: ${mk.stderr.slice(-300)}`);
|
|
98
|
+
// The scratch checkout is left in place (it holds only copied test files): nothing is force-removed, and a
|
|
99
|
+
// directory the job did not finish with is evidence, not litter.
|
|
100
|
+
for (const f of diff.files.filter((x) => x.status !== 'D' && /(^|\/)tests?\/|\.(test|spec)\.[cm]?[jt]sx?$/i.test(x.path))) {
|
|
101
|
+
fs.mkdirSync(path.dirname(path.join(scratch, f.path)), { recursive: true });
|
|
102
|
+
fs.copyFileSync(path.join(wt, f.path), path.join(scratch, f.path));
|
|
103
|
+
}
|
|
104
|
+
linkModules(nodeModules, scratch);
|
|
105
|
+
const before = runVitest(runSandboxed, scratch, present);
|
|
106
|
+
const after = runVitest(runSandboxed, wt, present);
|
|
107
|
+
return { verdict: judgeTestValidity({ before, after, newTests: present }), before, after, newTests: present };
|
|
108
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// scripts/issue-autofix-exec.mjs — the ONE place the autofix job (ADR-107) touches child_process, plus the
|
|
2
|
+
// primary-checkout guard. Every git/gh/npm/node call in the orchestrator, the release path and the reporter goes
|
|
3
|
+
// through the exec this module builds, so two rails live here and nowhere else to be forgotten:
|
|
4
|
+
// * assertCommandAllowed (no force, no merge, no delete) runs on EVERY argv before it is spawned;
|
|
5
|
+
// * a command whose cwd (or `git -C` dir) is inside the developer's primary checkout is refused.
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
9
|
+
import { AutofixAbort, assertCommandAllowed } from './issue-autofix-policy.mjs';
|
|
10
|
+
|
|
11
|
+
/** realpath of the nearest existing ancestor + the not-yet-existing tail (macOS /var -> /private/var safe). */
|
|
12
|
+
function realish(p) {
|
|
13
|
+
let cur = path.resolve(String(p));
|
|
14
|
+
const rest = [];
|
|
15
|
+
while (!fs.existsSync(cur)) {
|
|
16
|
+
const up = path.dirname(cur);
|
|
17
|
+
if (up === cur) break;
|
|
18
|
+
rest.unshift(path.basename(cur));
|
|
19
|
+
cur = up;
|
|
20
|
+
}
|
|
21
|
+
let base = cur;
|
|
22
|
+
try { base = fs.realpathSync(cur); } catch { /* keep the unresolved path */ }
|
|
23
|
+
return path.join(base, ...rest);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Throws AutofixAbort('S2','primary-checkout') when `p` is the primary checkout or anywhere inside it. */
|
|
27
|
+
export function resolveWorkPath(p, { primary }) {
|
|
28
|
+
const real = realish(p);
|
|
29
|
+
const prim = realish(primary);
|
|
30
|
+
if (real === prim || real.startsWith(prim + path.sep)) {
|
|
31
|
+
throw new AutofixAbort('S2', 'primary-checkout', `${real} is inside the primary checkout ${prim}`);
|
|
32
|
+
}
|
|
33
|
+
return real;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// git global options that take a value, so the sub-command is the first token after them.
|
|
37
|
+
function gitSub(args) {
|
|
38
|
+
const a = args.map(String);
|
|
39
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
40
|
+
if (a[i] === '-C' || a[i] === '-c') { i += 1; continue; }
|
|
41
|
+
if (a[i].startsWith('-')) continue;
|
|
42
|
+
return { sub: a[i], rest: a.slice(i + 1) };
|
|
43
|
+
}
|
|
44
|
+
return { sub: '', rest: [] };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** True for a command that WRITES (to a remote, the clone, or a worktree): a --dry-run records and skips these. */
|
|
48
|
+
export function isWriteCommand(cmd, args, { ghBin } = {}) {
|
|
49
|
+
const a = (args || []).map(String);
|
|
50
|
+
const bin = path.basename(String(cmd));
|
|
51
|
+
if (bin === 'git') {
|
|
52
|
+
const { sub, rest } = gitSub(a);
|
|
53
|
+
if (['push', 'commit', 'cherry-pick', 'clone', 'add'].includes(sub)) return true;
|
|
54
|
+
return sub === 'worktree' && ['add', 'remove'].includes(rest[0]);
|
|
55
|
+
}
|
|
56
|
+
if (String(cmd) === ghBin || bin === 'gh') {
|
|
57
|
+
const [g, s] = a;
|
|
58
|
+
if (g === 'issue') return ['comment', 'close', 'edit'].includes(s);
|
|
59
|
+
if (g === 'pr') return ['create', 'close'].includes(s);
|
|
60
|
+
if (g === 'workflow') return s === 'run';
|
|
61
|
+
if (g === 'variable') return s === 'set';
|
|
62
|
+
if (g === 'api') return a.some((t) => /dispatches/.test(t));
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
if (bin === 'npm') return a[0] === 'ci' || (a[0] === 'run' && a[1] === 'version:set');
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** True for a command that reaches GitHub with a write (push, comment, label, close, PR, workflow, dispatch, variable). */
|
|
70
|
+
export function isRemoteWrite(cmd, args, { ghBin } = {}) {
|
|
71
|
+
const a = (args || []).map(String);
|
|
72
|
+
const bin = path.basename(String(cmd));
|
|
73
|
+
if (bin === 'git') return gitSub(a).sub === 'push';
|
|
74
|
+
return (String(cmd) === ghBin || bin === 'gh') && isWriteCommand(cmd, a, { ghBin });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* exec(cmd, args, {cwd, env, timeoutMs, input}) -> {status, stdout, stderr, signal, timedOut}. Never throws on a
|
|
79
|
+
* non-zero exit; throws AutofixAbort for a forbidden argv or a primary-checkout cwd. dryRun skips every write;
|
|
80
|
+
* rehearse skips only writes that reach GitHub (local worktrees and commits in the job's own clone still happen).
|
|
81
|
+
*/
|
|
82
|
+
export function makeExec({ dryRun = false, rehearse = false, log = () => {}, primary = null, ghBin = 'gh', env = process.env } = {}) {
|
|
83
|
+
let stage = 'S0';
|
|
84
|
+
const exec = (cmd, args = [], opts = {}) => {
|
|
85
|
+
const argv = args.map(String);
|
|
86
|
+
assertCommandAllowed(cmd, argv, stage);
|
|
87
|
+
if (primary) {
|
|
88
|
+
if (opts.cwd) resolveWorkPath(opts.cwd, { primary });
|
|
89
|
+
const c = argv.indexOf('-C');
|
|
90
|
+
if (path.basename(String(cmd)) === 'git' && c !== -1 && argv[c + 1]) resolveWorkPath(argv[c + 1], { primary });
|
|
91
|
+
}
|
|
92
|
+
const line = [cmd, ...argv.map((t) => (t.length > 160 ? `${t.slice(0, 160)}…` : t))].join(' ');
|
|
93
|
+
if ((dryRun && isWriteCommand(cmd, argv, { ghBin })) || (rehearse && isRemoteWrite(cmd, argv, { ghBin }))) {
|
|
94
|
+
log(`[dry-run, not executed] ${line}`);
|
|
95
|
+
return { status: 0, stdout: '', stderr: '', signal: null, timedOut: false, skipped: true };
|
|
96
|
+
}
|
|
97
|
+
log(line);
|
|
98
|
+
const r = spawnSync(cmd, argv, { cwd: opts.cwd, env: opts.env || env, encoding: 'utf8', timeout: opts.timeoutMs,
|
|
99
|
+
input: opts.input, maxBuffer: 256 * 1024 * 1024, killSignal: 'SIGTERM' });
|
|
100
|
+
const timedOut = r.error?.code === 'ETIMEDOUT';
|
|
101
|
+
return { status: r.status, stdout: r.stdout || '', stderr: r.stderr || (r.error && !timedOut ? String(r.error.message) : ''),
|
|
102
|
+
signal: r.signal || null, timedOut };
|
|
103
|
+
};
|
|
104
|
+
exec.setStage = (s) => { stage = s; };
|
|
105
|
+
return exec;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* notify.sh exit code: 0 delivered, 1 failed, 2 paging off. The job runs from its own runner worktree, which has no
|
|
110
|
+
* .env, so the topic comes from $NTFY_TOPIC or ~/.cache/ruvnet-brain/ntfy-topic — the file job-heartbeat.sh reads.
|
|
111
|
+
* NTFY_TOPIC set but empty stays an explicit opt-out.
|
|
112
|
+
*/
|
|
113
|
+
export function defaultNotify(root, env = process.env) {
|
|
114
|
+
return (title, body, priority = 'default') => {
|
|
115
|
+
let topic = env.NTFY_TOPIC;
|
|
116
|
+
if (topic === undefined) {
|
|
117
|
+
try { topic = fs.readFileSync(path.join(env.HOME || '', '.cache', 'ruvnet-brain', 'ntfy-topic'), 'utf8').trim(); } catch { topic = undefined; }
|
|
118
|
+
}
|
|
119
|
+
const r = spawnSync('sh', [path.join(root, 'scripts', 'notify.sh'), title, body, priority, 'autofix'],
|
|
120
|
+
{ encoding: 'utf8', timeout: 30_000, env: topic === undefined ? env : { ...env, NTFY_TOPIC: topic } });
|
|
121
|
+
return Number.isInteger(r.status) ? r.status : 1;
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Stops the Mac sleeping for as long as this process lives (best effort). */
|
|
126
|
+
export function keepAwake(pid = process.pid) {
|
|
127
|
+
try { spawn('caffeinate', ['-i', '-w', String(pid)], { stdio: 'ignore', detached: true }).unref(); } catch { /* best effort */ }
|
|
128
|
+
}
|