cawdev-cli 1.1.1 → 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.
@@ -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
+ }
package/mcp/server.mjs CHANGED
@@ -2390,6 +2390,14 @@ function formatFileDeps(map, path) {
2390
2390
  /** How many finding lines a reading prints before saying "and N more" — R302. */
2391
2391
  const FINDING_LINES = 50;
2392
2392
 
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
+
2393
2401
  /**
2394
2402
  * The reading as a session should read it — R302: one line for the default
2395
2403
  * branch, one for the branch asked about, then the open findings.
@@ -2415,6 +2423,12 @@ function formatQualityReading(reading, branch) {
2415
2423
  const parts = project.error
2416
2424
  ? [`the last read failed: ${project.error}`, `before that: gate ${project.gate ?? 'NONE'}`]
2417
2425
  : [`gate ${project.gate ?? 'NONE'}`];
2426
+ // R587: which version was analysed, right after the verdict it belongs to.
2427
+ if (project.analysedVersion) parts.push(`v${project.analysedVersion.replace(/^v/i, '')}`);
2428
+ // R587: the conditions the gate failed on, the cause a session would otherwise guess at.
2429
+ if (project.gateConditions?.length) {
2430
+ parts.push(`failing: ${project.gateConditions.map(describeCondition).join(', ')}`);
2431
+ }
2418
2432
  parts.push(
2419
2433
  `${count(project.bugs)} bugs`,
2420
2434
  `${count(project.vulnerabilities)} vulnerabilities`,
@@ -2423,6 +2437,7 @@ function formatQualityReading(reading, branch) {
2423
2437
  `coverage ${percent(project.coverage)}`,
2424
2438
  );
2425
2439
  if (project.analysedAt) parts.push(`analysed ${project.analysedAt}`);
2440
+ if (project.newCodeSince) parts.push(`new code since ${project.newCodeSince}`);
2426
2441
  if (project.dashboardUrl) parts.push(project.dashboardUrl);
2427
2442
  lines.push(`Default branch: ${parts.join(' · ')}`);
2428
2443
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cawdev-cli",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "cawdev's terminal client and runner daemon — zero dependencies, on purpose",
5
5
  "type": "module",
6
6
  "bin": {
package/runner/runner.mjs CHANGED
@@ -30,6 +30,7 @@ import { bannerLines, tintLog } from './banner.mjs';
30
30
  import { codeMapOf } from '../lib/code-map.mjs';
31
31
  import { sonarqubeLaunch } from '../lib/sonarqube-mcp.mjs';
32
32
  import { parseProperties, sonarAsker, waitForAnalysis } from '../lib/sonar-analysis.mjs';
33
+ import { workspaceVersion } from '../lib/version-files.mjs';
33
34
  import { usageLimitOf } from '../lib/usage-limit.mjs';
34
35
  import { TranscriptBatch } from '../lib/transcript-batch.mjs';
35
36
  import { parseUsage } from '../lib/usage-report.mjs';
@@ -2704,9 +2705,16 @@ async function scanQuality(config, run, cwd, branch) {
2704
2705
  workDir = await mkdtemp(join(tmpdir(), 'cawdev-sonar-'));
2705
2706
  const deadline = Date.now() + quality.timeoutSeconds * 1000;
2706
2707
  const remaining = () => Math.max(1000, deadline - Date.now());
2708
+ // R587: the version the CODE at this checkout declares. Never a guess and
2709
+ // never `git describe`: a version that moves per commit would move
2710
+ // SonarQube's new-code baseline at every scan.
2711
+ const found = await workspaceVersion(cwd, run.versionFiles);
2712
+ const version = found?.version ?? null;
2713
+ log(versionLogLine(found, run.versionFiles));
2707
2714
  const scannerArgs = (withBranch) => [
2708
2715
  `-Dsonar.host.url=${url}`,
2709
2716
  `-Dsonar.projectKey=${key}`,
2717
+ ...(version ? [`-Dsonar.projectVersion=${version}`] : []),
2710
2718
  ...(withBranch ? [`-Dsonar.branch.name=${branch}`] : []),
2711
2719
  `-Dsonar.scm.revision=${headSha}`,
2712
2720
  `-Dsonar.working.directory=${workDir}`,
@@ -2727,7 +2735,7 @@ async function scanQuality(config, run, cwd, branch) {
2727
2735
  // function's own try/catch, which `return somePromise` would skip —
2728
2736
  // control leaves before the promise settles.
2729
2737
  return await scanAsScratchProject(
2730
- { quality, run, cwd, branch, key, url, token, headSha, workDir, deadline, report });
2738
+ { quality, run, cwd, branch, key, url, token, headSha, workDir, deadline, report, version });
2731
2739
  }
2732
2740
  // 'main', and known refused: no reason to run the branch scan first.
2733
2741
  const ran = await runSkillCommand(quality.command, scannerArgs(false), { cwd, env, timeoutMs: remaining() });
@@ -2760,7 +2768,7 @@ async function scanQuality(config, run, cwd, branch) {
2760
2768
  if (/sonar\.branch\.name.*(Developer Edition|above is required)/is.test(ran.said)) {
2761
2769
  if (quality.whenBranchesRefused === 'project') {
2762
2770
  return await scanAsScratchProject(
2763
- { quality, run, cwd, branch, key, url, token, headSha, workDir, deadline, report });
2771
+ { quality, run, cwd, branch, key, url, token, headSha, workDir, deadline, report, version });
2764
2772
  }
2765
2773
  if (quality.whenBranchesRefused !== 'main') {
2766
2774
  return report({
@@ -2868,7 +2876,14 @@ function shortHash(text) {
2868
2876
  * always `NONE`: a quality gate over a whole scratch project would judge
2869
2877
  * code this branch did not write.
2870
2878
  */
2871
- async function scanAsScratchProject({ quality, run, cwd, branch, key, url, token, headSha, workDir, deadline, report }) {
2879
+ /** The one line the scan logs about its version: found, declared but empty, or never declared (R587). */
2880
+ function versionLogLine(found, declared) {
2881
+ if (found) return ` quality: version ${found.version} (${found.file})`;
2882
+ if (declared?.length > 0) return ' quality: no version found in the declared version files: scanned without a version';
2883
+ return ' quality: no version file declared: scanned without a version';
2884
+ }
2885
+
2886
+ async function scanAsScratchProject({ quality, run, cwd, branch, key, url, token, headSha, workDir, deadline, report, version = null }) {
2872
2887
  const baseName = run.baseBranch ?? run.defaultBranch ?? 'main';
2873
2888
  const ref = await firstRef(cwd, [`origin/${baseName}`, baseName]);
2874
2889
  const mergeBase = ref ? await git(cwd, ['merge-base', 'HEAD', ref]).catch(() => null) : null;
@@ -2882,6 +2897,7 @@ async function scanAsScratchProject({ quality, run, cwd, branch, key, url, token
2882
2897
  `-Dsonar.host.url=${url}`,
2883
2898
  `-Dsonar.projectKey=${scratchKey}`,
2884
2899
  `-Dsonar.projectName=${key} · ${branch}`,
2900
+ ...(version ? [`-Dsonar.projectVersion=${version}`] : []),
2885
2901
  `-Dsonar.scm.revision=${headSha}`,
2886
2902
  `-Dsonar.working.directory=${workDir}`,
2887
2903
  ];
@@ -6858,6 +6874,9 @@ async function startRun(config, offered, workspace, source = null) {
6858
6874
  // R316. Whether the project SAYS its server refuses branches — null is
6859
6875
  // "not sure, try the branch scan first", today's behaviour.
6860
6876
  run.sonarBranches = claimed.sonarBranches ?? null;
6877
+ // R587. The project's declared version files: the scan reads the first it
6878
+ // can parse from the workspace and sends it as sonar.projectVersion.
6879
+ run.versionFiles = Array.isArray(claimed.versionFiles) ? claimed.versionFiles : [];
6861
6880
  // R69. Whether this is the same conversation picked back up, and which one.
6862
6881
  // Null on an ordinary claim, which is nearly all of them.
6863
6882
  resume = claimed.resume ?? null;