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.
@@ -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
+ }
@@ -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
- /** 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]}`);
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
- const { task: current } = await ask(`/api/ce/task?id=${encodeURIComponent(ceTaskId)}`);
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.`);
@@ -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
- const answer = `${answered.answeredByEmail} answered:\n\n${answered.answer}`;
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.');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cawdev-cli",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "cawdev's terminal client and runner daemon — zero dependencies, on purpose",
5
5
  "type": "module",
6
6
  "bin": {