cawdev-cli 1.2.0 → 1.2.2
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 +9 -1
- package/lib/gate-condition.mjs +11 -0
- package/lib/sonar-analysis.mjs +55 -9
- package/lib/stage-tools.mjs +33 -2
- package/mcp/server.mjs +44 -12
- package/package.json +1 -1
- package/runner/README.md +294 -33
- package/runner/attach.mjs +1927 -123
- package/runner/card-import.mjs +115 -0
- package/runner/cawdev.mjs +70 -1
- package/runner/diff-view.mjs +252 -0
- package/runner/git-lock.mjs +73 -0
- package/runner/runner.mjs +508 -39
- package/runner/session-cards.mjs +660 -0
- package/runner/shots.mjs +214 -0
- package/runner/sign-in.mjs +29 -2
- package/runner/split.mjs +307 -74
package/lib/finish-check.mjs
CHANGED
|
@@ -21,6 +21,13 @@ import { execFile } from 'node:child_process';
|
|
|
21
21
|
import { notAllowed, protectedChanges } from './protected.mjs';
|
|
22
22
|
import { findSecret } from './secrets.mjs';
|
|
23
23
|
|
|
24
|
+
// R604. Where a SHOW stage leaves what it wants shown (stage-tools.mjs names it).
|
|
25
|
+
// Never committed: the daemon keeps it out of git with `.git/info/exclude`,
|
|
26
|
+
// deletes it after the upload, and the filter in `leftOver` is the backstop.
|
|
27
|
+
import { SHOTS_DIR } from './stage-tools.mjs';
|
|
28
|
+
|
|
29
|
+
export { SHOTS_DIR };
|
|
30
|
+
|
|
24
31
|
/** The profiles whose run is a branch somebody will build on. */
|
|
25
32
|
export const COMMITS_ITS_WORK = new Set(['CODE']);
|
|
26
33
|
|
|
@@ -57,7 +64,8 @@ export async function leftOver(cwd, branch, { git = runGit } = {}) {
|
|
|
57
64
|
} catch {
|
|
58
65
|
return null;
|
|
59
66
|
}
|
|
60
|
-
const dirty = status.split('\n').map((line) => line.slice(3).trim()).filter(Boolean)
|
|
67
|
+
const dirty = status.split('\n').map((line) => line.slice(3).trim()).filter(Boolean)
|
|
68
|
+
.filter((path) => !path.startsWith(SHOTS_DIR));
|
|
61
69
|
const current = (await git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']).catch(() => '')).trim();
|
|
62
70
|
const remote = (await git(cwd, ['remote']).catch(() => '')).split('\n')[0]?.trim() || null;
|
|
63
71
|
const exists = await git(cwd, ['rev-parse', '--verify', '-q', `refs/heads/${branch}`])
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// R586 — one failed quality-gate condition in words. Shared by the MCP server
|
|
2
|
+
// (`quality_get`) and `sonar.sh` (tools/quality/scan.mjs) so the two cannot
|
|
3
|
+
// drift. Zero dependencies.
|
|
4
|
+
|
|
5
|
+
/** One failed gate condition, as SonarQube words it: `new_violations 1287 > 0`. A rating's step is its letter. */
|
|
6
|
+
export function describeCondition({ metric, comparator, threshold, actual }) {
|
|
7
|
+
const letter = (step) => ('12345'.includes(step) && step.length === 1 ? 'ABCDE'['12345'.indexOf(step)] : step);
|
|
8
|
+
if (metric.endsWith('_rating')) return `${metric} ${letter(actual)} worse than ${letter(threshold)}`;
|
|
9
|
+
const symbol = { GT: '>', LT: '<', EQ: '=', NE: '!=' }[comparator] ?? comparator;
|
|
10
|
+
return `${metric} ${actual} ${symbol} ${threshold}`;
|
|
11
|
+
}
|
package/lib/sonar-analysis.mjs
CHANGED
|
@@ -4,14 +4,48 @@
|
|
|
4
4
|
// for the server's analysis task. One place, so the two cannot drift (R584).
|
|
5
5
|
// Zero dependencies, like all of tools/.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
export const SONAR_REQUEST_TIMEOUT_MS = 30_000;
|
|
8
|
+
|
|
9
|
+
const isAbort = (error) =>
|
|
10
|
+
['TimeoutError', 'AbortError'].includes(error?.name) || ['TimeoutError', 'AbortError'].includes(error?.cause?.name);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A GET against the SonarQube server a scan just reported to, authorised with the
|
|
14
|
+
* scanner's own token. Every request is bounded (R622): by `requestTimeoutMs`, by
|
|
15
|
+
* the asker's `deadline`, and by a per-call `timeoutMs`, whichever is smallest.
|
|
16
|
+
* A cut-off request throws an error with `code === 'SONAR_TIMEOUT'`, and
|
|
17
|
+
* `byDeadline` true when the deadline or the per-call limit set the limit.
|
|
18
|
+
*/
|
|
19
|
+
export function sonarAsker(serverUrl, token, { requestTimeoutMs = SONAR_REQUEST_TIMEOUT_MS, deadline = Infinity } = {}) {
|
|
20
|
+
return async (path, { timeoutMs } = {}) => {
|
|
21
|
+
const pathOnly = path.split('?')[0];
|
|
22
|
+
const left = Math.min(deadline - Date.now(), timeoutMs ?? Infinity);
|
|
23
|
+
const byDeadline = left < requestTimeoutMs;
|
|
24
|
+
const limit = Math.max(1, Math.min(requestTimeoutMs, left));
|
|
25
|
+
try {
|
|
26
|
+
const response = await fetch(`${serverUrl}${path}`, {
|
|
27
|
+
headers: { authorization: `Bearer ${token}` },
|
|
28
|
+
signal: AbortSignal.timeout(limit),
|
|
29
|
+
});
|
|
30
|
+
if (!response.ok) {
|
|
31
|
+
await response.body?.cancel().catch(() => {});
|
|
32
|
+
throw new Error(`SonarQube answered ${response.status} to ${pathOnly}`);
|
|
33
|
+
}
|
|
34
|
+
return await response.json();
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (!isAbort(error)) {
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
const timeout = new Error(
|
|
40
|
+
byDeadline
|
|
41
|
+
? `SonarQube did not answer ${pathOnly} before the reading's time ran out`
|
|
42
|
+
: `SonarQube did not answer ${pathOnly} within ${Math.ceil(limit / 1000)}s`,
|
|
43
|
+
);
|
|
44
|
+
timeout.code = 'SONAR_TIMEOUT';
|
|
45
|
+
timeout.limitMs = limit;
|
|
46
|
+
timeout.byDeadline = byDeadline;
|
|
47
|
+
throw timeout;
|
|
13
48
|
}
|
|
14
|
-
return response.json();
|
|
15
49
|
};
|
|
16
50
|
}
|
|
17
51
|
|
|
@@ -19,16 +53,28 @@ export function sonarAsker(serverUrl, token) {
|
|
|
19
53
|
* Waits for a queued analysis to settle — shared by every scan path, BRANCH,
|
|
20
54
|
* MAIN and PROJECT alike. Throws rather than reports: every caller runs
|
|
21
55
|
* inside `scanQuality`'s own try/catch, which turns it into an error reading.
|
|
56
|
+
* Each poll is limited to the time left (R622), so one unanswered request
|
|
57
|
+
* cannot outlive the deadline.
|
|
22
58
|
*/
|
|
23
59
|
export async function waitForAnalysis(ask, ceTaskId, deadline, timeoutSeconds) {
|
|
24
60
|
let settled = null;
|
|
25
61
|
while (Date.now() < deadline) {
|
|
26
|
-
|
|
62
|
+
let current;
|
|
63
|
+
try {
|
|
64
|
+
({ task: current } = await ask(`/api/ce/task?id=${encodeURIComponent(ceTaskId)}`, {
|
|
65
|
+
timeoutMs: deadline - Date.now(),
|
|
66
|
+
}));
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (error?.code === 'SONAR_TIMEOUT' && error.byDeadline) {
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
27
73
|
if (['SUCCESS', 'FAILED', 'CANCELED'].includes(current?.status)) {
|
|
28
74
|
settled = current;
|
|
29
75
|
break;
|
|
30
76
|
}
|
|
31
|
-
await new Promise((wake) => setTimeout(wake, 3000));
|
|
77
|
+
await new Promise((wake) => setTimeout(wake, Math.min(3000, Math.max(0, deadline - Date.now()))));
|
|
32
78
|
}
|
|
33
79
|
if (!settled) {
|
|
34
80
|
throw new Error(`SonarQube had not finished analysing after ${timeoutSeconds}s.`);
|
package/lib/stage-tools.mjs
CHANGED
|
@@ -152,6 +152,12 @@ export const CARD_WRITE = `${CAWDEV}roadmap_create`;
|
|
|
152
152
|
*/
|
|
153
153
|
export const SUGGEST = `${CAWDEV}backlog_file`;
|
|
154
154
|
|
|
155
|
+
/**
|
|
156
|
+
* The one folder a SHOW stage may write — R604. Repository-relative, with the
|
|
157
|
+
* trailing slash; `finish-check.mjs` has the same constant for its own reason.
|
|
158
|
+
*/
|
|
159
|
+
export const SHOTS_DIR = '.cawdev/shots/';
|
|
160
|
+
|
|
155
161
|
/**
|
|
156
162
|
* Reads that only mean something once the work exists — R302.
|
|
157
163
|
*
|
|
@@ -312,7 +318,7 @@ export function canRunThings(tool) {
|
|
|
312
318
|
* tools rather than a coding run's. That ordering is what keeps R28's decision
|
|
313
319
|
* the outer one.
|
|
314
320
|
*
|
|
315
|
-
* @param stage one of PLAN, VERIFY, IMPLEMENT, TEST, MEMORY
|
|
321
|
+
* @param stage one of PLAN, VERIFY, IMPLEMENT, TEST, SHOW, MEMORY
|
|
316
322
|
* @param profileTools what the run's profile allows
|
|
317
323
|
* @param testMode R129, and only TEST reads it: `TESTBOOK` narrows the stage to
|
|
318
324
|
* what cannot run anything. Absent — an older platform that does not send it —
|
|
@@ -366,6 +372,20 @@ export function toolsForStage(stage, profileTools, testMode, options = {}) {
|
|
|
366
372
|
case 'VERIFY':
|
|
367
373
|
case 'MEMORY':
|
|
368
374
|
return readOnly(false);
|
|
375
|
+
case 'SHOW':
|
|
376
|
+
// R604. The reads of a read-only stage, plus every Bash the profile
|
|
377
|
+
// allows (SHOW has to start the app and run the repository's own
|
|
378
|
+
// Playwright), plus an Edit narrowed to the shots folder. Bare Write,
|
|
379
|
+
// Edit, MultiEdit and NotebookEdit never pass through. Bash can still
|
|
380
|
+
// write anywhere, and that is said plainly: this list is not the guard.
|
|
381
|
+
// The guard is the daemon's tree check after the stage, and SHOW is
|
|
382
|
+
// spawned with no permission mode so anything off this list goes to a
|
|
383
|
+
// person.
|
|
384
|
+
return [...new Set([
|
|
385
|
+
...readOnly(false),
|
|
386
|
+
...allowed.filter((tool) => /^Bash\b/.test(tool)),
|
|
387
|
+
`Edit(./${SHOTS_DIR}**)`,
|
|
388
|
+
])];
|
|
369
389
|
case 'TEST':
|
|
370
390
|
// R129. The mirror of the claim above, with the other question asked: a
|
|
371
391
|
// stage that may WRITE and cannot RUN. Same filtering of the PROFILE's
|
|
@@ -420,7 +440,7 @@ function readsGit(toolLine) {
|
|
|
420
440
|
* person who needs to know is the one reading the run.
|
|
421
441
|
*/
|
|
422
442
|
export function driftedFrom(stage, toolLine, testMode, readOnlyExperts = [], options = {}) {
|
|
423
|
-
const readOnly = stage === 'PLAN' || stage === 'VERIFY' || stage === 'MEMORY';
|
|
443
|
+
const readOnly = stage === 'PLAN' || stage === 'VERIFY' || stage === 'MEMORY' || stage === 'SHOW';
|
|
424
444
|
// R129. A TEST stage set to write the testbook has its own thing it should
|
|
425
445
|
// not have been able to do, and the same reason for saying so out loud.
|
|
426
446
|
const testbook = stage === 'TEST' && testMode === 'TESTBOOK';
|
|
@@ -445,6 +465,17 @@ export function driftedFrom(stage, toolLine, testMode, readOnlyExperts = [], opt
|
|
|
445
465
|
if (!canChangeThings(name)) {
|
|
446
466
|
return null;
|
|
447
467
|
}
|
|
468
|
+
// R604. SHOW ran something, which it is allowed to (the app, Playwright), or
|
|
469
|
+
// wrote into its own folder, which is what it is for.
|
|
470
|
+
if (stage === 'SHOW') {
|
|
471
|
+
if (/^Bash\b/.test(name)) {
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
if (/^(Write|Edit|MultiEdit)/.test(name) && String(toolLine).includes(SHOTS_DIR)
|
|
475
|
+
&& !String(toolLine).includes('..')) {
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
448
479
|
// R147. A delegation to an expert this stage was allowed is the one call
|
|
449
480
|
// `canChangeThings` names a writer that is not one here. `describeCall`
|
|
450
481
|
// writes it as `Agent(<name>) …`, and the name is the CLI's qualified one.
|
package/mcp/server.mjs
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
} from '../lib/protected.mjs';
|
|
27
27
|
import { findDestructive, withinScope } from '../lib/secrets.mjs';
|
|
28
28
|
import { byCodeUnit } from '../lib/order.mjs';
|
|
29
|
+
import { describeCondition } from '../lib/gate-condition.mjs';
|
|
29
30
|
import { COMMITS_ITS_WORK, isUnfinished, leftOver, notDoneYet } from '../lib/finish-check.mjs';
|
|
30
31
|
|
|
31
32
|
const NAME = 'cawdev';
|
|
@@ -300,10 +301,14 @@ function renderRounds(where) {
|
|
|
300
301
|
* by counting is an agent one skipped question away from acting on the wrong
|
|
301
302
|
* one.
|
|
302
303
|
*/
|
|
304
|
+
/** R612: the platform closes a question with no person behind it; it reaches us as this. */
|
|
305
|
+
const THE_PLATFORM = '(the platform)';
|
|
306
|
+
const bySomeone = (email) => Boolean(email) && email !== THE_PLATFORM;
|
|
307
|
+
|
|
303
308
|
function renderRound(asked, answered) {
|
|
304
309
|
const title = asked?.title ?? 'The round';
|
|
305
310
|
const lines = answered.map((question, at) => {
|
|
306
|
-
const said = question.answeredByEmail
|
|
311
|
+
const said = bySomeone(question.answeredByEmail)
|
|
307
312
|
? `${question.answeredByEmail}: ${question.answer}`
|
|
308
313
|
: question.answer;
|
|
309
314
|
const opinions = (question.opinions ?? [])
|
|
@@ -328,7 +333,10 @@ function renderAnswer(answered) {
|
|
|
328
333
|
.map((opinion) => ` ${opinion.authorEmail}: ${opinion.body}`)
|
|
329
334
|
.join('\n');
|
|
330
335
|
|
|
331
|
-
|
|
336
|
+
// R612: no answeredByEmail means the platform closed it, not a person.
|
|
337
|
+
const answer = bySomeone(answered.answeredByEmail)
|
|
338
|
+
? `${answered.answeredByEmail} answered:\n\n${answered.answer}`
|
|
339
|
+
: `Nobody answered — the question was closed:\n\n${answered.answer}`;
|
|
332
340
|
return said ? `${answer}\n\nWhat people said before deciding:\n${said}` : answer;
|
|
333
341
|
}
|
|
334
342
|
|
|
@@ -1692,7 +1700,7 @@ const TOOLS = [
|
|
|
1692
1700
|
if (answered) {
|
|
1693
1701
|
return renderRound(null, answered);
|
|
1694
1702
|
}
|
|
1695
|
-
return `Still unfinished. Call await_group again with group_id ${args.group_id}.`;
|
|
1703
|
+
return `Still unfinished. Call await_group again with group_id ${args.group_id} to keep waiting. If you carry on instead, the round stays in their inbox — and the run reads as waiting on them — until this stage ends; then it is closed, and an answer given after that is refused.`;
|
|
1696
1704
|
},
|
|
1697
1705
|
},
|
|
1698
1706
|
|
|
@@ -1773,7 +1781,7 @@ const TOOLS = [
|
|
|
1773
1781
|
if (answered) {
|
|
1774
1782
|
return renderAnswer(answered);
|
|
1775
1783
|
}
|
|
1776
|
-
return `Still nothing. Call await_answer again with question_id ${args.question_id}.`;
|
|
1784
|
+
return `Still nothing. Call await_answer again with question_id ${args.question_id} to keep waiting. If you carry on instead, the question stays in their inbox — and the run reads as waiting on them — until this stage ends; then it is closed, and an answer given after that is refused.`;
|
|
1777
1785
|
},
|
|
1778
1786
|
},
|
|
1779
1787
|
|
|
@@ -2005,6 +2013,36 @@ async function protectedAllowedNow(config, project, runId, policy) {
|
|
|
2005
2013
|
return allowed;
|
|
2006
2014
|
}
|
|
2007
2015
|
|
|
2016
|
+
/**
|
|
2017
|
+
* R318: what a branch scan made of the finding the card was filed from, in the
|
|
2018
|
+
* words the console uses (frontend `core/proven-words.ts`): still reported
|
|
2019
|
+
* beats not checked beats fixed. Null when the card has no finding to prove.
|
|
2020
|
+
*/
|
|
2021
|
+
function provenLine(row) {
|
|
2022
|
+
const findings = row.findings ?? [];
|
|
2023
|
+
if (!findings.length) return null;
|
|
2024
|
+
const name = (finding) => {
|
|
2025
|
+
const rule = finding.rule ? finding.rule.replace(/^[^:]*:/, '') : finding.key;
|
|
2026
|
+
const file = finding.path ? finding.path.split('/').pop() : null;
|
|
2027
|
+
return file ? `${rule} in ${file}` : rule;
|
|
2028
|
+
};
|
|
2029
|
+
const named = (verdict) => findings.filter((finding) => finding.verdict === verdict).map(name).join(', ');
|
|
2030
|
+
const plural = (n, noun) => `${n} new ${noun}${n === 1 ? '' : 's'}`;
|
|
2031
|
+
const bugs = row.newBugs ?? null;
|
|
2032
|
+
const vulnerabilities = row.newVulnerabilities ?? null;
|
|
2033
|
+
let clause = null;
|
|
2034
|
+
if (bugs !== null || vulnerabilities !== null) {
|
|
2035
|
+
const parts = [];
|
|
2036
|
+
if (bugs) parts.push(plural(bugs, 'bug'));
|
|
2037
|
+
if (vulnerabilities) parts.push(vulnerabilities === 1 ? '1 new vulnerability' : `${vulnerabilities} new vulnerabilities`);
|
|
2038
|
+
clause = parts.length ? parts.join(' and ') : '0 new problems';
|
|
2039
|
+
}
|
|
2040
|
+
const tail = clause ? ` · ${clause}` : '';
|
|
2041
|
+
if (named('STILL_OPEN')) return `Still reported: ${named('STILL_OPEN')}${tail}`;
|
|
2042
|
+
if (named('UNCHECKED')) return `Not checked: ${named('UNCHECKED')} (restart the daemon)`;
|
|
2043
|
+
return named('FIXED') ? `Fixed ${named('FIXED')}${tail}` : null;
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2008
2046
|
/**
|
|
2009
2047
|
* The protected-file gate on `report done` — R562. Null when the branch is
|
|
2010
2048
|
* clean of unallowed protected changes (or nothing can be judged), else the
|
|
@@ -2390,14 +2428,6 @@ function formatFileDeps(map, path) {
|
|
|
2390
2428
|
/** How many finding lines a reading prints before saying "and N more" — R302. */
|
|
2391
2429
|
const FINDING_LINES = 50;
|
|
2392
2430
|
|
|
2393
|
-
/** One failed gate condition, as SonarQube words it: `new_violations 1287 > 0`. A rating's step is its letter. */
|
|
2394
|
-
function describeCondition({ metric, comparator, threshold, actual }) {
|
|
2395
|
-
const letter = (step) => ('12345'.includes(step) && step.length === 1 ? 'ABCDE'['12345'.indexOf(step)] : step);
|
|
2396
|
-
if (metric.endsWith('_rating')) return `${metric} ${letter(actual)} worse than ${letter(threshold)}`;
|
|
2397
|
-
const symbol = { GT: '>', LT: '<', EQ: '=', NE: '!=' }[comparator] ?? comparator;
|
|
2398
|
-
return `${metric} ${actual} ${symbol} ${threshold}`;
|
|
2399
|
-
}
|
|
2400
|
-
|
|
2401
2431
|
/**
|
|
2402
2432
|
* The reading as a session should read it — R302: one line for the default
|
|
2403
2433
|
* branch, one for the branch asked about, then the open findings.
|
|
@@ -2467,6 +2497,8 @@ function formatQualityReading(reading, branch) {
|
|
|
2467
2497
|
if (row.dashboardUrl) parts.push(row.dashboardUrl);
|
|
2468
2498
|
lines.push(`Branch ${asked.branch}: ${parts.join(' · ')}`);
|
|
2469
2499
|
if (row.warning) lines.push(` ${row.warning}`);
|
|
2500
|
+
const proven = provenLine(row);
|
|
2501
|
+
if (proven) lines.push(` ${proven}`);
|
|
2470
2502
|
}
|
|
2471
2503
|
} else if (branch === null) {
|
|
2472
2504
|
lines.push('No branch was read: outside a run, name one to see its gate and new problems.');
|