cawdev-cli 1.1.0 → 1.2.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/lib/finish-check.mjs +34 -1
- package/lib/harness-prompt.mjs +6 -0
- package/lib/order.mjs +7 -0
- package/lib/protected.mjs +225 -0
- package/lib/roadmap-format.mjs +4 -2
- package/lib/runner-config.mjs +9 -0
- package/lib/secrets.mjs +2 -2
- package/lib/sonar-analysis.mjs +52 -0
- package/lib/sonarqube-mcp.mjs +50 -0
- package/lib/stage-tools.mjs +52 -1
- package/lib/version-files.mjs +99 -0
- package/mcp/README.md +5 -6
- package/mcp/server.mjs +183 -25
- package/package.json +1 -1
- package/runner/README.md +79 -0
- package/runner/attach.mjs +46 -1
- package/runner/cawdev.mjs +20 -2
- package/runner/configure.mjs +62 -0
- package/runner/git-host.mjs +298 -5
- package/runner/pause-note.mjs +104 -0
- package/runner/push-guard.mjs +333 -0
- package/runner/runner.mjs +681 -180
package/lib/finish-check.mjs
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// finish that pushes a key is worse than one that leaves a file behind.
|
|
19
19
|
|
|
20
20
|
import { execFile } from 'node:child_process';
|
|
21
|
+
import { notAllowed, protectedChanges } from './protected.mjs';
|
|
21
22
|
import { findSecret } from './secrets.mjs';
|
|
22
23
|
|
|
23
24
|
/** The profiles whose run is a branch somebody will build on. */
|
|
@@ -109,8 +110,16 @@ export function notDoneYet(left) {
|
|
|
109
110
|
* was), and `{ ok: false, reason }` otherwise, in words for the run's summary.
|
|
110
111
|
* Never forces; a remote that has moved is a reason, not something to win.
|
|
111
112
|
*/
|
|
112
|
-
export async function settleBranch(cwd, branch,
|
|
113
|
+
export async function settleBranch(cwd, branch,
|
|
114
|
+
{ git = runGit, push, label = 'cawdev', protected: policy = null } = {}) {
|
|
113
115
|
const left = await leftOver(cwd, branch, { git });
|
|
116
|
+
// R562. Before the "nothing left" return: a protected change already
|
|
117
|
+
// committed and pushed (by a CLI that has no gate of its own) is exactly what
|
|
118
|
+
// this door exists to catch.
|
|
119
|
+
const held = left ? await heldProtected(cwd, policy) : [];
|
|
120
|
+
if (held.length) {
|
|
121
|
+
return { ok: false, reason: protectedHeld(held) };
|
|
122
|
+
}
|
|
114
123
|
if (!left || !isUnfinished(left)) {
|
|
115
124
|
return { ok: true, note: null };
|
|
116
125
|
}
|
|
@@ -122,6 +131,10 @@ export async function settleBranch(cwd, branch, { git = runGit, push, label = 'c
|
|
|
122
131
|
};
|
|
123
132
|
}
|
|
124
133
|
|
|
134
|
+
// R562. The daemon has no session to ask, so a protected change nobody
|
|
135
|
+
// allowed fails the run (above, before anything is staged), held in the
|
|
136
|
+
// checkout. ASK fails too: Carry on, and the next `report done` asks.
|
|
137
|
+
|
|
125
138
|
let committed = [];
|
|
126
139
|
if (left.dirty.length) {
|
|
127
140
|
await git(cwd, ['add', '-A']);
|
|
@@ -159,6 +172,10 @@ export async function settleBranch(cwd, branch, { git = runGit, push, label = 'c
|
|
|
159
172
|
: null,
|
|
160
173
|
};
|
|
161
174
|
}
|
|
175
|
+
const heldNow = await heldProtected(cwd, policy);
|
|
176
|
+
if (heldNow.length) {
|
|
177
|
+
return { ok: false, reason: protectedHeld(heldNow) };
|
|
178
|
+
}
|
|
162
179
|
const pushed = await push(cwd, branch);
|
|
163
180
|
if (!pushed.ok) {
|
|
164
181
|
return {
|
|
@@ -176,3 +193,19 @@ export async function settleBranch(cwd, branch, { git = runGit, push, label = 'c
|
|
|
176
193
|
: `The daemon pushed ${branch}, which the session had committed and not pushed.`,
|
|
177
194
|
};
|
|
178
195
|
}
|
|
196
|
+
|
|
197
|
+
/** The protected paths this checkout changes that nobody has allowed. */
|
|
198
|
+
export async function heldProtected(cwd, policy) {
|
|
199
|
+
if (!policy?.globs?.length || !policy.base) {
|
|
200
|
+
return [];
|
|
201
|
+
}
|
|
202
|
+
const changed = await protectedChanges(cwd, policy.base, policy.globs);
|
|
203
|
+
return notAllowed(changed, policy.allowed ?? []);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The sentence for a protected change at a door with nobody to ask. */
|
|
207
|
+
export function protectedHeld(paths) {
|
|
208
|
+
return `${named(paths)} ${paths.length === 1 ? 'is' : 'are'} protected in this project and `
|
|
209
|
+
+ "were changed without a person's leave, so nothing was committed or pushed. The work is "
|
|
210
|
+
+ 'held in the checkout.';
|
|
211
|
+
}
|
package/lib/harness-prompt.mjs
CHANGED
|
@@ -105,6 +105,12 @@ export function harnessPrompt({
|
|
|
105
105
|
+ 'least one tool under the prefix, before this stage is finished.\n'
|
|
106
106
|
+ mustServers.map((each) => `- \`${each.prefix}__…\` — ${each.name}`).join('\n'));
|
|
107
107
|
}
|
|
108
|
+
// R319. A server that knows when it is worth reaching for says so, required
|
|
109
|
+
// or not; one that does not still gets nothing.
|
|
110
|
+
const adviceFor = (mcpServers ?? []).filter((each) => each.when);
|
|
111
|
+
if (adviceFor.length) {
|
|
112
|
+
reach.push(adviceFor.map((each) => `- \`${each.prefix}__…\` (${each.name}): ${each.when}`).join('\n'));
|
|
113
|
+
}
|
|
108
114
|
if (reach.length) {
|
|
109
115
|
parts.push(`## What this session may reach\n\n${reach.join('\n\n')}`);
|
|
110
116
|
}
|
package/lib/order.mjs
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Code-unit order: what Array#sort does to strings by default and what Java's
|
|
2
|
+
// String.compareTo does, said out loud. Never localeCompare here: ICU weighs
|
|
3
|
+
// `-`, `.` and `_` differently, and an export must not churn (R315) nor a
|
|
4
|
+
// refusal reorder its paths.
|
|
5
|
+
export function byCodeUnit(a, b) {
|
|
6
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
7
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// Protected files — R562.
|
|
2
|
+
//
|
|
3
|
+
// A project lists globs an agent may not change. Three layers enforce it and
|
|
4
|
+
// this is the part that does not depend on the CLI: `protectedChanges` compares
|
|
5
|
+
// the branch with the commit it was cut from, so it catches an Edit, a shell
|
|
6
|
+
// `git checkout main -- pom.xml`, a `git mv`, a `sed -i` and a build step alike,
|
|
7
|
+
// on every CLI and on a machine set to allow everything.
|
|
8
|
+
//
|
|
9
|
+
// Zero dependencies, like the rest of tools/.
|
|
10
|
+
|
|
11
|
+
import { execFile } from 'node:child_process';
|
|
12
|
+
import { realpathSync } from 'node:fs';
|
|
13
|
+
import { matches, normalise } from './secrets.mjs';
|
|
14
|
+
import { byCodeUnit } from './order.mjs';
|
|
15
|
+
|
|
16
|
+
/** The modes a project can choose. REFUSE is what an unreadable policy means. */
|
|
17
|
+
export const MODES = ['REFUSE', 'ASK'];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The policy out of a shield, whatever shape it arrived in.
|
|
21
|
+
*
|
|
22
|
+
* Unreadable or missing is REFUSE with no globs — never a way of allowing.
|
|
23
|
+
*/
|
|
24
|
+
export function protectedPolicy(shield) {
|
|
25
|
+
const globs = Array.isArray(shield?.protectedPaths)
|
|
26
|
+
? shield.protectedPaths.filter((glob) => typeof glob === 'string' && glob.trim())
|
|
27
|
+
.map((glob) => glob.trim())
|
|
28
|
+
: [];
|
|
29
|
+
const mode = String(shield?.protectedMode ?? '').toUpperCase() === 'ASK' ? 'ASK' : 'REFUSE';
|
|
30
|
+
const allowed = Array.isArray(shield?.protectedAllowed)
|
|
31
|
+
? shield.protectedAllowed.filter((path) => typeof path === 'string')
|
|
32
|
+
: [];
|
|
33
|
+
return { globs, mode, allowed };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Whether a path, absolute or relative to the checkout, is a protected one.
|
|
38
|
+
*
|
|
39
|
+
* A path that leaves the checkout is not protected here — it is the scope
|
|
40
|
+
* rule's business — and a symlink is followed when it exists, so a link into a
|
|
41
|
+
* protected directory is the directory.
|
|
42
|
+
*/
|
|
43
|
+
export function isProtected(path, root, globs) {
|
|
44
|
+
if (typeof path !== 'string' || !path || !Array.isArray(globs) || globs.length === 0) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
const base = normalise(root);
|
|
48
|
+
const candidates = [normalise(path.startsWith('/') ? path : `${base}/${path}`)];
|
|
49
|
+
const real = resolveReal(candidates[0]);
|
|
50
|
+
if (real && real !== candidates[0]) {
|
|
51
|
+
candidates.push(real);
|
|
52
|
+
}
|
|
53
|
+
let realBase = base;
|
|
54
|
+
try {
|
|
55
|
+
realBase = realpathSync(base);
|
|
56
|
+
} catch {
|
|
57
|
+
// The checkout is not there; the lexical answer stands.
|
|
58
|
+
}
|
|
59
|
+
return candidates.some((resolved) => {
|
|
60
|
+
for (const root of [base, realBase]) {
|
|
61
|
+
if (resolved === root || resolved.startsWith(`${root}/`)) {
|
|
62
|
+
const relative = resolved === root ? '' : resolved.slice(root.length + 1);
|
|
63
|
+
return globs.some((glob) => matchesHere(relative, glob.replace(/^\.\//, '')));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Whether a relative path is under a glob. On macOS and Windows the default
|
|
72
|
+
* volume is case-insensitive, so `POM.XML` IS `pom.xml` there; comparing
|
|
73
|
+
* without case protects a little more than a case-sensitive volume needs, which
|
|
74
|
+
* is the safe direction to be wrong in.
|
|
75
|
+
*/
|
|
76
|
+
function matchesHere(path, glob) {
|
|
77
|
+
return process.platform === 'darwin' || process.platform === 'win32'
|
|
78
|
+
? matches(path.toLowerCase(), glob.toLowerCase())
|
|
79
|
+
: matches(path, glob);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The real path of the longest part that exists, with the rest appended. */
|
|
83
|
+
function resolveReal(path) {
|
|
84
|
+
const parts = path.split('/');
|
|
85
|
+
for (let keep = parts.length; keep > 1; keep -= 1) {
|
|
86
|
+
const head = parts.slice(0, keep).join('/') || '/';
|
|
87
|
+
try {
|
|
88
|
+
const real = realpathSync(head);
|
|
89
|
+
return normalise([real, ...parts.slice(keep)].join('/'));
|
|
90
|
+
} catch {
|
|
91
|
+
// Not there yet; try the parent.
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function runGit(cwd, args) {
|
|
98
|
+
return new Promise((resolve, reject) => {
|
|
99
|
+
execFile('git', args, { cwd, maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
100
|
+
if (error) {
|
|
101
|
+
reject(new Error(stderr?.trim() || error.message));
|
|
102
|
+
} else {
|
|
103
|
+
resolve(stdout);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The protected paths this branch changes, sorted and unique.
|
|
111
|
+
*
|
|
112
|
+
* The committed diff against `base` (a diff, not a log, so a change later
|
|
113
|
+
* reverted does not count; `--no-renames`, so moving a protected file away is
|
|
114
|
+
* caught on its old path) plus the working tree. Empty when there is nothing to
|
|
115
|
+
* judge: no globs, or no base.
|
|
116
|
+
*/
|
|
117
|
+
export async function protectedChanges(cwd, base, globs, { git = runGit } = {}) {
|
|
118
|
+
if (!base || !Array.isArray(globs) || globs.length === 0) {
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
const touched = new Set();
|
|
122
|
+
// A git failure THROWS. An unreadable base (garbage-collected, a shallow
|
|
123
|
+
// clone) is not a reason to say the branch is clean: the callers turn a throw
|
|
124
|
+
// into a refusal, which is the direction this fails in.
|
|
125
|
+
const diff = await git(cwd, ['diff', '--name-only', '--no-renames', '-z', base, 'HEAD']);
|
|
126
|
+
for (const path of diff.split('\0')) {
|
|
127
|
+
if (path) {
|
|
128
|
+
touched.add(path);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const status = await git(cwd, ['status', '--porcelain', '-z', '--untracked-files=all']);
|
|
132
|
+
const entries = status.split('\0');
|
|
133
|
+
for (let at = 0; at < entries.length; at += 1) {
|
|
134
|
+
const entry = entries[at];
|
|
135
|
+
if (entry.length < 4) {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
touched.add(entry.slice(3));
|
|
139
|
+
// A rename or copy carries the old path as the next field.
|
|
140
|
+
if (entry[0] === 'R' || entry[0] === 'C' || entry[1] === 'R' || entry[1] === 'C') {
|
|
141
|
+
at += 1;
|
|
142
|
+
if (entries[at]) {
|
|
143
|
+
touched.add(entries[at]);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return [...touched]
|
|
148
|
+
.filter((path) => globs.some((glob) => matchesHere(path, glob.replace(/^\.\//, ''))))
|
|
149
|
+
.sort(byCodeUnit);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The base a branch was cut from: the merge-base with its upstream, else the claim's commit. */
|
|
153
|
+
export async function protectedBase(cwd, baseBranch, fallback, { git = runGit } = {}) {
|
|
154
|
+
if (baseBranch) {
|
|
155
|
+
for (const ref of [`origin/${baseBranch}`, baseBranch]) {
|
|
156
|
+
try {
|
|
157
|
+
const sha = (await git(cwd, ['merge-base', ref, 'HEAD'])).trim();
|
|
158
|
+
if (sha) {
|
|
159
|
+
return sha;
|
|
160
|
+
}
|
|
161
|
+
} catch {
|
|
162
|
+
// Try the next ref.
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return fallback || null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** What is changed and not allowed. */
|
|
170
|
+
export function notAllowed(changed, allowed = []) {
|
|
171
|
+
const leave = new Set(allowed);
|
|
172
|
+
return changed.filter((path) => !leave.has(path));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The sentence a refusal gives, shared by every door. */
|
|
176
|
+
export function protectedRefusal(paths, base) {
|
|
177
|
+
const list = paths.join(', ');
|
|
178
|
+
return `Not finished — this branch changes protected file(s) the project never lets an agent `
|
|
179
|
+
+ `change: ${list}. Restore them (git checkout ${base ? base.slice(0, 12) : '<base>'} -- `
|
|
180
|
+
+ `<path>, or delete what you added), commit, push, and report done again.`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The paragraph every prompt carries. Null when nothing is protected. */
|
|
184
|
+
export function protectedLine(policy, already = []) {
|
|
185
|
+
const globs = policy?.globs ?? [];
|
|
186
|
+
if (!globs.length) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
const how = policy.mode === 'ASK'
|
|
190
|
+
? 'Changing one stops and asks a person first, and an allowed change is marked on the card in '
|
|
191
|
+
+ 'red; prefer a design that leaves them alone.'
|
|
192
|
+
: 'An agent never changes them — not with an edit, not with the shell, not with git; the run '
|
|
193
|
+
+ 'cannot finish while the branch changes one.';
|
|
194
|
+
const lines = [
|
|
195
|
+
`**Protected files.** This project protects: ${globs.map((glob) => `\`${glob}\``).join(', ')}. `
|
|
196
|
+
+ `${how} Check your plan against this list before you start.`,
|
|
197
|
+
];
|
|
198
|
+
if (already.length) {
|
|
199
|
+
lines.push(`This branch already changes ${already.map((path) => `\`${path}\``).join(', ')}, `
|
|
200
|
+
+ 'which are protected; deal with that first.');
|
|
201
|
+
}
|
|
202
|
+
return lines.join('\n\n');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Rules for the CLI's permission list, one per glob.
|
|
207
|
+
*
|
|
208
|
+
* Only `Edit(path)` is matched — Claude Code rejects `Write(path)` as "not
|
|
209
|
+
* matched by file permission checks" and applies Edit rules to every
|
|
210
|
+
* file-editing tool, NotebookEdit included (measured on 2.1.307; see
|
|
211
|
+
* `protectFiles` in the runner).
|
|
212
|
+
*/
|
|
213
|
+
export function denyRules(globs) {
|
|
214
|
+
return (globs ?? []).map((glob) => `Edit(./${glob.replace(/^\.\//, '')})`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** A path relative to the checkout, or null when it is outside it. */
|
|
218
|
+
export function relativeTo(path, root) {
|
|
219
|
+
const base = normalise(root);
|
|
220
|
+
const resolved = normalise(String(path).startsWith('/') ? String(path) : `${base}/${path}`);
|
|
221
|
+
if (resolved === base) {
|
|
222
|
+
return '';
|
|
223
|
+
}
|
|
224
|
+
return resolved.startsWith(`${base}/`) ? resolved.slice(base.length + 1) : null;
|
|
225
|
+
}
|
package/lib/roadmap-format.mjs
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// is not a fixed point and the generated file churns on every release. Keeping
|
|
5
5
|
// both halves in one file is how they stay agreed.
|
|
6
6
|
|
|
7
|
+
import { byCodeUnit } from './order.mjs';
|
|
8
|
+
|
|
7
9
|
/**
|
|
8
10
|
* What to call a card — R127 — and, since R221, what identifies one.
|
|
9
11
|
*
|
|
@@ -506,7 +508,7 @@ function originWord(origin) {
|
|
|
506
508
|
case 'SONARQUBE':
|
|
507
509
|
return 'SonarQube';
|
|
508
510
|
case 'FEEDBACK':
|
|
509
|
-
return 'Feedback';
|
|
511
|
+
return origin.label ? `Feedback (from ${origin.label})` : 'Feedback';
|
|
510
512
|
case 'APPLICATION':
|
|
511
513
|
return origin.label ? `An application (${origin.label})` : 'An application';
|
|
512
514
|
case 'SESSION':
|
|
@@ -613,7 +615,7 @@ function renderEntry(entry) {
|
|
|
613
615
|
}
|
|
614
616
|
if (entry.tags?.length) {
|
|
615
617
|
// R315. Sorted, so the export does not churn on the set's own order.
|
|
616
|
-
lines.push(`Tags: ${[...entry.tags].sort().join(', ')}`);
|
|
618
|
+
lines.push(`Tags: ${[...entry.tags].sort(byCodeUnit).join(', ')}`);
|
|
617
619
|
}
|
|
618
620
|
if (entry.assignee?.username) {
|
|
619
621
|
// R331. Nothing for a card with nobody, so today's files stay byte-identical.
|
package/lib/runner-config.mjs
CHANGED
|
@@ -25,6 +25,15 @@
|
|
|
25
25
|
* Adding a key that IS captured would make the daemon claim it applied
|
|
26
26
|
* something it did not, which is worse than saying restart.
|
|
27
27
|
*/
|
|
28
|
+
/**
|
|
29
|
+
* R577: the name this machine registers under — the environment first, then the
|
|
30
|
+
* file, then a default. The daemon and the CLI both ask it, so `cawdev config
|
|
31
|
+
* planning-limit` finds the same machine the daemon registered.
|
|
32
|
+
*/
|
|
33
|
+
export function runnerNameOf(file, env = process.env) {
|
|
34
|
+
return env.CAWDEV_RUNNER_NAME ?? file?.name ?? 'this-machine';
|
|
35
|
+
}
|
|
36
|
+
|
|
28
37
|
export const HOT_SWAPPABLE = Object.freeze(['agentCommands', 'grantable', 'acceptsRulesFromConsole',
|
|
29
38
|
'autoWorkspaces']);
|
|
30
39
|
|
package/lib/secrets.mjs
CHANGED
|
@@ -217,7 +217,7 @@ export function withinScope(path, root, scope = null) {
|
|
|
217
217
|
* daemon's cwd is not the checkout. A silent dependence on where the daemon
|
|
218
218
|
* happens to be standing is exactly the bug this function exists to not have.
|
|
219
219
|
*/
|
|
220
|
-
function normalise(path) {
|
|
220
|
+
export function normalise(path) {
|
|
221
221
|
const absolute = path.startsWith('/');
|
|
222
222
|
const out = [];
|
|
223
223
|
for (const part of path.split('/')) {
|
|
@@ -244,7 +244,7 @@ function normalise(path) {
|
|
|
244
244
|
* zero-dep constraint is what makes `tools/` a directory somebody can read
|
|
245
245
|
* before running it against their repositories.
|
|
246
246
|
*/
|
|
247
|
-
function matches(path, glob) {
|
|
247
|
+
export function matches(path, glob) {
|
|
248
248
|
if (typeof glob !== 'string' || !glob) {
|
|
249
249
|
return false;
|
|
250
250
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// The SonarQube server side of a scan, shared by the runner daemon
|
|
2
|
+
// (tools/runner/runner.mjs, scanQuality) and `sonar.sh` (tools/quality/scan.mjs):
|
|
3
|
+
// the scanner's report-task.txt, a GET with the scanner's own token, and the wait
|
|
4
|
+
// for the server's analysis task. One place, so the two cannot drift (R584).
|
|
5
|
+
// Zero dependencies, like all of tools/.
|
|
6
|
+
|
|
7
|
+
/** A GET against the SonarQube server a scan just reported to, authorised with the scanner's own token. */
|
|
8
|
+
export function sonarAsker(serverUrl, token) {
|
|
9
|
+
return async (path) => {
|
|
10
|
+
const response = await fetch(`${serverUrl}${path}`, { headers: { authorization: `Bearer ${token}` } });
|
|
11
|
+
if (!response.ok) {
|
|
12
|
+
throw new Error(`SonarQube answered ${response.status} to ${path.split('?')[0]}`);
|
|
13
|
+
}
|
|
14
|
+
return response.json();
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Waits for a queued analysis to settle — shared by every scan path, BRANCH,
|
|
20
|
+
* MAIN and PROJECT alike. Throws rather than reports: every caller runs
|
|
21
|
+
* inside `scanQuality`'s own try/catch, which turns it into an error reading.
|
|
22
|
+
*/
|
|
23
|
+
export async function waitForAnalysis(ask, ceTaskId, deadline, timeoutSeconds) {
|
|
24
|
+
let settled = null;
|
|
25
|
+
while (Date.now() < deadline) {
|
|
26
|
+
const { task: current } = await ask(`/api/ce/task?id=${encodeURIComponent(ceTaskId)}`);
|
|
27
|
+
if (['SUCCESS', 'FAILED', 'CANCELED'].includes(current?.status)) {
|
|
28
|
+
settled = current;
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
await new Promise((wake) => setTimeout(wake, 3000));
|
|
32
|
+
}
|
|
33
|
+
if (!settled) {
|
|
34
|
+
throw new Error(`SonarQube had not finished analysing after ${timeoutSeconds}s.`);
|
|
35
|
+
}
|
|
36
|
+
if (settled.status !== 'SUCCESS') {
|
|
37
|
+
throw new Error(settled.errorMessage ?? `SonarQube ${settled.status.toLowerCase()} the analysis.`);
|
|
38
|
+
}
|
|
39
|
+
return settled;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `key=value` lines — the shape of the scanner's `report-task.txt`. */
|
|
43
|
+
export function parseProperties(text) {
|
|
44
|
+
const parsed = {};
|
|
45
|
+
for (const line of text.split('\n')) {
|
|
46
|
+
const at = line.indexOf('=');
|
|
47
|
+
if (at > 0) {
|
|
48
|
+
parsed[line.slice(0, at).trim()] = line.slice(at + 1).trim();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return parsed;
|
|
52
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// R319. What the daemon adds to SonarSource's MCP server before it is spawned.
|
|
2
|
+
//
|
|
3
|
+
// The registry row says WHAT to run (`java -jar <file name>`); this says where
|
|
4
|
+
// the jar is on this machine and under which credentials, because those are
|
|
5
|
+
// facts about the machine and the platform must not hold them. Pure, so the
|
|
6
|
+
// three refusals can be tested without a daemon.
|
|
7
|
+
|
|
8
|
+
import { isAbsolute, join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
/** The directory, on this machine, that holds the jar the row names. */
|
|
11
|
+
export const JAR_DIR_ENV = 'CAWDEV_SONARQUBE_MCP_DIR';
|
|
12
|
+
|
|
13
|
+
const trimSlash = (text) => String(text).replace(/\/+$/, '');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The arguments and environment to spawn the server with, or the sentence
|
|
17
|
+
* saying why it is not attached. Never throws.
|
|
18
|
+
*
|
|
19
|
+
* - the token is `process.env[quality.tokenEnv]` — the credential R300 already
|
|
20
|
+
* holds — and is put in the SERVER's environment only;
|
|
21
|
+
* - the URL is the machine's override, else the project's connection, so a
|
|
22
|
+
* project on another server gets that server;
|
|
23
|
+
* - a machine with no `quality` block for the project, no token, no URL or no
|
|
24
|
+
* jar directory does not get the server, and is told which.
|
|
25
|
+
*/
|
|
26
|
+
export function sonarqubeLaunch({ skill, quality, sonarUrl, env, storagePath }) {
|
|
27
|
+
const name = skill.name ?? skill.key;
|
|
28
|
+
const dir = env[JAR_DIR_ENV];
|
|
29
|
+
if (!dir || !isAbsolute(dir)) {
|
|
30
|
+
return { skip: `${name} was not attached: set ${JAR_DIR_ENV} to the absolute directory that `
|
|
31
|
+
+ `holds ${skill.args.find((arg) => String(arg).endsWith('.jar')) ?? 'the server jar'}.` };
|
|
32
|
+
}
|
|
33
|
+
if (!quality) {
|
|
34
|
+
return { skip: `${name} was not attached: this machine has no "quality" block for this `
|
|
35
|
+
+ 'project, so it holds no scanner token for the server to use.' };
|
|
36
|
+
}
|
|
37
|
+
const token = env[quality.tokenEnv];
|
|
38
|
+
if (!token) {
|
|
39
|
+
return { skip: `${name} was not attached: $${quality.tokenEnv} is not set on this machine.` };
|
|
40
|
+
}
|
|
41
|
+
const url = quality.url ?? sonarUrl;
|
|
42
|
+
if (!url) {
|
|
43
|
+
return { skip: `${name} was not attached: neither the project's connection nor this machine's `
|
|
44
|
+
+ 'quality block names a SonarQube server.' };
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
args: skill.args.map((arg) => (String(arg).endsWith('.jar') && !isAbsolute(arg) ? join(dir, arg) : arg)),
|
|
48
|
+
env: { SONARQUBE_TOKEN: token, SONARQUBE_URL: trimSlash(url), STORAGE_PATH: storagePath },
|
|
49
|
+
};
|
|
50
|
+
}
|
package/lib/stage-tools.mjs
CHANGED
|
@@ -22,6 +22,15 @@
|
|
|
22
22
|
* named string behind one named option — rather than an entry in `CAWDEV_READS`,
|
|
23
23
|
* which would hand a write to every read-only stage of every profile and call it
|
|
24
24
|
* a read.
|
|
25
|
+
*
|
|
26
|
+
* The second exception — R572 — is {@link SUGGEST}, `backlog_file`, and every
|
|
27
|
+
* read-only stage keeps it. It files FEEDBACK: a pending item a person accepts or
|
|
28
|
+
* refuses. It changes no file, no card, no status and no discussion, so "cannot
|
|
29
|
+
* change anything" survives it in every sense the claim was ever about. It is
|
|
30
|
+
* here because the read-only stages are where a session reads the most code and
|
|
31
|
+
* notices the most that is not this card's, and the only place that left them to
|
|
32
|
+
* say so was a report nobody files from. Named for `CARD_WRITE`'s reason: a
|
|
33
|
+
* writer let through on purpose, not a writer renamed a read.
|
|
25
34
|
*/
|
|
26
35
|
|
|
27
36
|
/** Reading git without being able to change it. */
|
|
@@ -98,6 +107,26 @@ export const CAWDEV_READS = new Set([
|
|
|
98
107
|
*/
|
|
99
108
|
const STAGE_READS = new Set([...CAWDEV_READS].filter((name) => name !== 'report'));
|
|
100
109
|
|
|
110
|
+
/**
|
|
111
|
+
* R319. SonarSource's server, judged by a NAMED list for the reason
|
|
112
|
+
* {@link CAWDEV_READS} is: shape is the worst signal available, and here it is
|
|
113
|
+
* wrong — `change_sonar_issue_status`, `toggle_automatic_analysis` and
|
|
114
|
+
* `create_webhook` are writers the loose end-of-name test below reads as reads.
|
|
115
|
+
* What is not on this list is a writer, including a tool a later version adds.
|
|
116
|
+
* Pinned in stage-tools.test.mjs against the tool list read at 1.29.0.
|
|
117
|
+
*/
|
|
118
|
+
export const SONARQUBE_READS = new Set([
|
|
119
|
+
'show_rule', 'list_languages', 'search_sonar_issues_in_projects',
|
|
120
|
+
'search_security_hotspots', 'show_security_hotspot', 'get_project_quality_gate_status',
|
|
121
|
+
'list_quality_gates', 'get_component_measures', 'search_metrics',
|
|
122
|
+
'search_my_sonarqube_projects', 'list_branches', 'list_pull_requests',
|
|
123
|
+
'get_raw_source', 'search_files_by_coverage', 'get_file_coverage_details',
|
|
124
|
+
'search_duplicated_files', 'get_duplications',
|
|
125
|
+
]);
|
|
126
|
+
|
|
127
|
+
/** The `mcp__sonarqube__` prefix, once. */
|
|
128
|
+
const SONARQUBE = 'mcp__sonarqube__';
|
|
129
|
+
|
|
101
130
|
/** The `mcp__cawdev__` prefix, once. */
|
|
102
131
|
const CAWDEV = 'mcp__cawdev__';
|
|
103
132
|
|
|
@@ -114,6 +143,15 @@ const REPORT = `${CAWDEV}report`;
|
|
|
114
143
|
*/
|
|
115
144
|
export const CARD_WRITE = `${CAWDEV}roadmap_create`;
|
|
116
145
|
|
|
146
|
+
/**
|
|
147
|
+
* The platform write every read-only stage may hold — R572. See the header.
|
|
148
|
+
*
|
|
149
|
+
* <p>A stage still only holds it when the PROFILE handed it over: this lets it
|
|
150
|
+
* through the read-only filter, it never adds it. `canChangeThings` goes on
|
|
151
|
+
* calling it a writer, which is what keeps it an exception.
|
|
152
|
+
*/
|
|
153
|
+
export const SUGGEST = `${CAWDEV}backlog_file`;
|
|
154
|
+
|
|
117
155
|
/**
|
|
118
156
|
* Reads that only mean something once the work exists — R302.
|
|
119
157
|
*
|
|
@@ -221,6 +259,14 @@ export function canChangeThings(tool) {
|
|
|
221
259
|
if (tool.startsWith(CAWDEV)) {
|
|
222
260
|
return !STAGE_READS.has(tool.slice(CAWDEV.length));
|
|
223
261
|
}
|
|
262
|
+
// The bare server (`mcp__sonarqube`) is Claude Code's whole-server grant and
|
|
263
|
+
// covers the writers too, so it is one.
|
|
264
|
+
if (tool === SONARQUBE.slice(0, -2)) {
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
if (tool.startsWith(SONARQUBE)) {
|
|
268
|
+
return !SONARQUBE_READS.has(tool.slice(SONARQUBE.length));
|
|
269
|
+
}
|
|
224
270
|
// Everybody else's MCP tools, where shape is all there is: one that writes
|
|
225
271
|
// usually says so in its name. A guess, and acceptable only because the
|
|
226
272
|
// alternative — treating every third-party MCP tool as a writer — would strip
|
|
@@ -302,7 +348,8 @@ export function toolsForStage(stage, profileTools, testMode, options = {}) {
|
|
|
302
348
|
const readOnly = (keepsCardWrite) => [
|
|
303
349
|
...allowed.filter((tool) => !canChangeThings(tool)
|
|
304
350
|
|| (delegates && DELEGATES.test(tool))
|
|
305
|
-
|| (keepsCardWrite && tool === CARD_WRITE)
|
|
351
|
+
|| (keepsCardWrite && tool === CARD_WRITE)
|
|
352
|
+
|| tool === SUGGEST),
|
|
306
353
|
...GIT_READS.filter((tool) => allowed.includes(tool) || allowed.includes('Bash(git *)')),
|
|
307
354
|
];
|
|
308
355
|
switch (stage) {
|
|
@@ -412,6 +459,10 @@ export function driftedFrom(stage, toolLine, testMode, readOnlyExperts = [], opt
|
|
|
412
459
|
if (stage === 'PLAN' && options.writesItsOwnCard && name === CARD_WRITE) {
|
|
413
460
|
return null;
|
|
414
461
|
}
|
|
462
|
+
// R572. Filing a suggestion is the other write a read-only stage was given.
|
|
463
|
+
if (name === SUGGEST) {
|
|
464
|
+
return null;
|
|
465
|
+
}
|
|
415
466
|
return `cawdev: the ${stage} stage called ${name}, which it should not have been able to. `
|
|
416
467
|
+ notice;
|
|
417
468
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// R587: the project's version out of a file that declares it. Moved from
|
|
2
|
+
// tools/release/plan.mjs so the release plan, `sonar.sh` (through
|
|
3
|
+
// tools/quality/scan.mjs) and the daemon's scan (runner.mjs, scanQuality) read
|
|
4
|
+
// a version one way. Pure and zero dependencies, like all of tools/.
|
|
5
|
+
//
|
|
6
|
+
// A file is told apart by its NAME: `pom.xml`, `package.json`, and a `.yaml` /
|
|
7
|
+
// `.yml` with an `info:` block (openapi.yaml). Anything else has no version
|
|
8
|
+
// here: null, never a guess.
|
|
9
|
+
|
|
10
|
+
import { readFile } from 'node:fs/promises';
|
|
11
|
+
import { basename, isAbsolute, resolve, sep } from 'node:path';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The project's own version in a pom: the first `<version>` OUTSIDE
|
|
15
|
+
* `<parent>`, which carries Spring Boot's, and outside `<dependencies>`,
|
|
16
|
+
* `<build>` and the rest, which carry their libraries'.
|
|
17
|
+
*/
|
|
18
|
+
export function pomVersion(pom) {
|
|
19
|
+
const top = pom
|
|
20
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
21
|
+
.replace(/<(parent|dependencies|dependencyManagement|build|profiles|properties)>[\s\S]*?<\/\1>/g, '');
|
|
22
|
+
const match = /<version>\s*([^<\s]+)\s*<\/version>/.exec(top);
|
|
23
|
+
return match ? match[1] : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `info.version` in openapi.yaml: a `version:` key directly under `info:`,
|
|
28
|
+
* at the block's own indentation: `info.description` is a block scalar
|
|
29
|
+
* with blank lines in it, and a deeper `version:` is somebody's schema.
|
|
30
|
+
*/
|
|
31
|
+
export function openapiVersion(yaml) {
|
|
32
|
+
const lines = yaml.split('\n');
|
|
33
|
+
const start = lines.findIndex((line) => /^info:\s*$/.test(line));
|
|
34
|
+
if (start === -1) return null;
|
|
35
|
+
let indent = null;
|
|
36
|
+
for (const line of lines.slice(start + 1)) {
|
|
37
|
+
if (!line.trim()) continue;
|
|
38
|
+
const own = /^(\s*)/.exec(line)[1].length;
|
|
39
|
+
if (own === 0) return null;
|
|
40
|
+
indent ??= own;
|
|
41
|
+
if (own !== indent) continue;
|
|
42
|
+
const match = /^\s*version:\s*['"]?([^'"\s]+)/.exec(line);
|
|
43
|
+
if (match) return match[1];
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The top-level `version` of a package.json, or null (not JSON, no version). */
|
|
49
|
+
export function packageJsonVersion(text) {
|
|
50
|
+
try {
|
|
51
|
+
const version = JSON.parse(text)?.version;
|
|
52
|
+
return typeof version === 'string' && version.trim() ? version.trim() : null;
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The version `text` declares, by what `path` is called; null for a kind it does not know. */
|
|
59
|
+
export function versionOf(path, text) {
|
|
60
|
+
const name = basename(String(path));
|
|
61
|
+
if (name === 'pom.xml') return pomVersion(text);
|
|
62
|
+
if (name === 'package.json') return packageJsonVersion(text);
|
|
63
|
+
if (/\.ya?ml$/i.test(name)) return openapiVersion(text);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The first declared file that yields a version: `{version, file}`, or null.
|
|
69
|
+
* `read(path)` returns the text, or throws for a file that is not there; both
|
|
70
|
+
* a throw and a non-string mean "not this one". Order is the project's.
|
|
71
|
+
*/
|
|
72
|
+
export async function firstVersion(files, read) {
|
|
73
|
+
for (const file of files ?? []) {
|
|
74
|
+
let text;
|
|
75
|
+
try { text = await read(file); } catch { continue; }
|
|
76
|
+
if (typeof text !== 'string') continue;
|
|
77
|
+
const version = versionOf(file, text);
|
|
78
|
+
if (version) return { version, file };
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* R587: the first of the project's declared version files that parses, read
|
|
85
|
+
* from the workspace: `{version, file}` or null. A file that is missing, does
|
|
86
|
+
* not parse, or would climb out of the checkout is skipped, never an error.
|
|
87
|
+
* Only a branch analysis has a new-code period of its own and never touches the
|
|
88
|
+
* project's gate; a MAIN analysis (`whenBranchesRefused: "main"`) is the one
|
|
89
|
+
* the version matters to, since SonarQube reads a versionless one as a change.
|
|
90
|
+
*/
|
|
91
|
+
export async function workspaceVersion(cwd, files) {
|
|
92
|
+
const root = resolve(cwd);
|
|
93
|
+
return firstVersion(files, async (file) => {
|
|
94
|
+
if (isAbsolute(file)) return null;
|
|
95
|
+
const path = resolve(root, file);
|
|
96
|
+
if (path !== root && !path.startsWith(`${root}${sep}`)) return null;
|
|
97
|
+
return readFile(path, 'utf8');
|
|
98
|
+
});
|
|
99
|
+
}
|