From 800c3cd2a53061357fb54a29c125954db8a294f7 Mon Sep 17 00:00:00 2001 From: Massimo Paladin <456050+mpaladin@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:08:04 +0200 Subject: [PATCH 1/5] CLI-613 Merge CAG subcommand tree into docs site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sonar context is a Commander passthrough, so the docs generator cannot discover CAG's clap-based subcommands on its own. Wire the docs build to download the pinned CAG binary, invoke the new `tool dump-cli-tree --pretty` command, and stitch the result into commands.json / llms.txt / the HTML tree under the sonar-context entry. New files: - build-scripts/docs/dump-cag-tree.ts — download, verify, and run dump - build-scripts/docs/merge-cag-tree.ts — exported mergeCagTree + ClidocCommand types - tests/fixtures/cag-cli-tree.json — captured CAG tree fixture - tests/unit/build-scripts/docs/merge-cag-tree.test.ts — 18 unit tests Note: docs/data/commands.json and docs/llms.txt are not updated here. They will be regenerated (bun run gen:docs) once the CAG version in SONAR_CONTEXT_AUGMENTATION_VERSION is bumped to a release that includes the dump-cli-tree command. Set CAG_BINARY_PATH= to use a local build during development. --- build-scripts/docs/dump-cag-tree.ts | 173 ++++++ build-scripts/docs/examples.ts | 32 + build-scripts/docs/generate-docs.ts | 45 +- build-scripts/docs/merge-cag-tree.ts | 151 +++++ tests/fixtures/cag-cli-tree.json | 547 ++++++++++++++++++ .../build-scripts/docs/merge-cag-tree.test.ts | 206 +++++++ 6 files changed, 1120 insertions(+), 34 deletions(-) create mode 100644 build-scripts/docs/dump-cag-tree.ts create mode 100644 build-scripts/docs/merge-cag-tree.ts create mode 100644 tests/fixtures/cag-cli-tree.json create mode 100644 tests/unit/build-scripts/docs/merge-cag-tree.test.ts diff --git a/build-scripts/docs/dump-cag-tree.ts b/build-scripts/docs/dump-cag-tree.ts new file mode 100644 index 000000000..fdfae53cf --- /dev/null +++ b/build-scripts/docs/dump-cag-tree.ts @@ -0,0 +1,173 @@ +/* + * SonarQube CLI + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +/** + * Download the pinned sonar-context-augmentation binary (if not already cached), + * invoke `tool dump-cli-tree --pretty`, and return the parsed command tree. + * + * Used by the docs build to merge CAG's subcommand surface into commands.json / llms.txt. + */ + +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { chmod,readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { extractFileFromTarGz } from '../../src/cli/commands/_common/install/tar.js'; +import { buildCagPlatformSuffix } from '../../src/lib/install-types.js'; +import { detectPlatform } from '../../src/lib/platform-detector.js'; +import { + SONAR_CONTEXT_AUGMENTATION_SIGNATURES, + SONAR_CONTEXT_AUGMENTATION_VERSION, + SONARSOURCE_PUBLIC_KEY, +} from '../../src/lib/signatures.js'; +import { + buildCagDownloadUrl, + downloadBinary, + verifyPgpSignature, +} from '../../src/lib/sonarsource-releases.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, '..', '..'); +const CAG_CACHE_DIR = join(ROOT, 'node_modules', '.cache', 'sonarqube-cli', 'cag-docs'); + +export interface CagOption { + long: string; + short?: string; + value_name?: string; + help?: string; + required: boolean; + default?: string; + num_args?: string; +} + +export interface CagCommand { + name: string; + about?: string; + options?: CagOption[]; + subcommands?: CagCommand[]; +} + +export interface CagCliTree { + version: string; + subcommands: CagCommand[]; +} + +/** + * Ensure the pinned CAG binary is cached locally and return its path. + * Verifies the PGP signature on first download. + * Throws on download failure, signature mismatch, or missing archive content. + */ +async function resolveCagBinary(): Promise { + const platform = detectPlatform(); + const platSuffix = buildCagPlatformSuffix(platform); + const version = SONAR_CONTEXT_AUGMENTATION_VERSION; + const cacheDir = join(CAG_CACHE_DIR, version); + + mkdirSync(cacheDir, { recursive: true }); + + const binaryName = `sonar-context-augmentation-${version}-${platSuffix}${platform.extension}`; + const binaryPath = join(cacheDir, binaryName); + + if (existsSync(binaryPath)) { + return binaryPath; + } + + const archiveUrl = buildCagDownloadUrl(version, platform); + const archivePath = `${binaryPath}.tar.gz`; + const ascPath = `${archivePath}.asc`; + + console.log(` Downloading sonar-context-augmentation ${version} for ${platSuffix}…`); + await downloadBinary(archiveUrl, archivePath); + await downloadBinary(`${archiveUrl}.asc`, ascPath); + + const [archiveBytes, armoredSignature] = await Promise.all([ + readFile(archivePath), + readFile(ascPath, 'utf-8'), + ]); + + const expected = SONAR_CONTEXT_AUGMENTATION_SIGNATURES[platSuffix]; + if (!expected) { + throw new Error( + `No pinned signature for sonar-context-augmentation on ${platSuffix}. ` + + `Run \`bun run fetch:signatures\` to refresh.`, + ); + } + if (expected !== armoredSignature.trim()) { + throw new Error( + `Signature mismatch for sonar-context-augmentation on ${platSuffix}: ` + + `the downloaded .asc does not match the pinned signature.`, + ); + } + await verifyPgpSignature(archiveBytes, armoredSignature, SONARSOURCE_PUBLIC_KEY); + + const binaryBytes = extractFileFromTarGz( + archiveBytes, + `sonar-context-augmentation${platform.extension}`, + ); + if (!binaryBytes) { + throw new Error( + `sonar-context-augmentation binary not found inside ${archiveUrl.split('/').at(-1)}.`, + ); + } + + writeFileSync(binaryPath, binaryBytes); + if (platform.os !== 'windows') { + await chmod(binaryPath, 0o755); + } + + return binaryPath; +} + +/** + * Download and run `sonar-context-augmentation tool dump-cli-tree --pretty`, + * returning the parsed JSON command tree. + * + * Set `CAG_BINARY_PATH` to skip the download and use a pre-built binary instead + * (useful for local development against an unreleased CAG build). + * + * Throws on download / verification failure or non-zero exit from the binary. + */ +export async function dumpCagTree(): Promise { + const overridePath = process.env['CAG_BINARY_PATH']; + let binaryPath: string; + if (overridePath) { + console.log(`Using CAG binary override: ${overridePath}`); + binaryPath = overridePath; + } else { + console.log('Fetching sonar-context-augmentation CLI tree for docs generation…'); + binaryPath = await resolveCagBinary(); + } + + const result = spawnSync(binaryPath, ['tool', 'dump-cli-tree', '--pretty'], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + + if (result.status !== 0 || result.error) { + const detail = result.error?.message ?? result.stderr ?? '(no output)'; + throw new Error( + `sonar-context-augmentation tool dump-cli-tree failed (exit ${result.status ?? 'null'}): ${detail}`, + ); + } + + return JSON.parse(result.stdout) as CagCliTree; +} diff --git a/build-scripts/docs/examples.ts b/build-scripts/docs/examples.ts index 8544070d4..c3a418942 100644 --- a/build-scripts/docs/examples.ts +++ b/build-scripts/docs/examples.ts @@ -139,6 +139,38 @@ export const EXAMPLES: Record = { description: 'Scan stdin for hardcoded secrets', }, ], + 'sonar context guidelines get': [ + { + command: 'sonar context guidelines get --languages rust', + description: 'Get coding guidelines for Rust before writing or editing code', + }, + { + command: + 'sonar context guidelines get --categories "Auth & Identity" "Exception & Error Handling" --languages java', + description: 'Get guidelines for specific categories in Java', + }, + ], + 'sonar context dependencies check': [ + { + command: 'sonar context dependencies check --purl "pkg:npm/lodash@4.17.21"', + description: 'Check an npm package for vulnerabilities and license compliance', + }, + { + command: + 'sonar context dependencies check --purl "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1"', + description: 'Check a Maven dependency before adding it to the project', + }, + ], + 'sonar context navigation search-signatures': [ + { + command: 'sonar context navigation search-signatures --pattern ".*Service" --limit 10', + description: 'Find classes or functions whose name ends with "Service"', + }, + { + command: 'sonar context navigation search-signatures --pattern ".*Repository" --output fqns', + description: 'List FQNs of all Repository types for use in follow-up queries', + }, + ], 'sonar config telemetry': [ { command: 'sonar config telemetry --enabled', diff --git a/build-scripts/docs/generate-docs.ts b/build-scripts/docs/generate-docs.ts index 6be37e70b..ad9350751 100644 --- a/build-scripts/docs/generate-docs.ts +++ b/build-scripts/docs/generate-docs.ts @@ -33,7 +33,10 @@ import type { Option } from 'commander'; import { version } from '../../package.json'; import { COMMAND_TREE } from '../../src/cli/command-tree'; import type { SonarCommand } from '../../src/cli/commands/_common/sonar-command'; +import { dumpCagTree } from './dump-cag-tree'; import { EXAMPLES } from './examples'; +import type { ClidocCommand } from './merge-cag-tree'; +import { mergeCagTree } from './merge-cag-tree'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, '..', '..'); @@ -50,40 +53,6 @@ function optionType( return opt.required || opt.optional ? 'string' : 'boolean'; } -interface ClidocArgument { - name: string; - description: string; - required: boolean; - variadic: boolean; -} - -interface ClidocOption { - flags: string; - long: string; - short: string | undefined; - description: string; - type: string; - required: boolean; - defaultValue: unknown; - allowedValues?: string[]; -} - -interface ClidocCommand { - id: string; - name: string; - fullName: string; - description: string; - isGroup: boolean; - isRoot: boolean; - requiresAuth: boolean; - depth: number; - parentId: string | null; - arguments: ClidocArgument[]; - options: ClidocOption[]; - examples: { command: string; description: string }[]; - children: string[]; -} - const allCommands: ClidocCommand[] = []; const help = COMMAND_TREE.createHelp(); @@ -163,6 +132,14 @@ for (const cmd of visibleTopLevel) { serializeCommand(cmd as SonarCommand, 'sonar', 1, rootId); } +// ── CAG subcommand tree merge ────────────────────────────────── +// sonar context [action] [args...] is a Commander passthrough. The docs +// generator cannot see CAG's clap-based subcommands via Commander, so we +// download the pinned CAG binary, invoke `tool dump-cli-tree --pretty`, and +// stitch the result into allCommands under the existing sonar-context entry. +const cagTree = await dumpCagTree(); +mergeCagTree(cagTree, allCommands); + const data = { version, commands: allCommands, diff --git a/build-scripts/docs/merge-cag-tree.ts b/build-scripts/docs/merge-cag-tree.ts new file mode 100644 index 000000000..741588b6a --- /dev/null +++ b/build-scripts/docs/merge-cag-tree.ts @@ -0,0 +1,151 @@ +/* + * SonarQube CLI + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +/** + * Merge a sonar-context-augmentation CLI tree (obtained via `tool dump-cli-tree`) + * into the flat ClidocCommand list produced by generate-docs.ts. + */ + +import type { CagCliTree, CagCommand, CagOption } from './dump-cag-tree'; +export type { CagCliTree, CagOption } from './dump-cag-tree'; +import { EXAMPLES } from './examples'; + +export interface ClidocArgument { + name: string; + description: string; + required: boolean; + variadic: boolean; +} + +export interface ClidocOption { + flags: string; + long: string; + short: string | undefined; + description: string; + type: string; + required: boolean; + defaultValue: unknown; + allowedValues?: string[]; +} + +export interface ClidocCommand { + id: string; + name: string; + fullName: string; + description: string; + isGroup: boolean; + isRoot: boolean; + requiresAuth: boolean; + depth: number; + parentId: string | null; + arguments: ClidocArgument[]; + options: ClidocOption[]; + examples: { command: string; description: string }[]; + children: string[]; +} + +/** + * Commands (by full name) that do not require a SonarQube connection. + * Every other CAG command is marked requiresAuth: true. + */ +export const CAG_NON_AUTH_FULL_NAMES = new Set([ + 'sonar context tool start', + 'sonar context tool stop', + 'sonar context tool status', + 'sonar context tool print-skill', +]); + +export function buildCagFlags(opt: CagOption): string { + const valuePart = opt.value_name ? ` <${opt.value_name}>` : ''; + const longFlag = `--${opt.long}${valuePart}`; + return opt.short ? `-${opt.short}, ${longFlag}` : longFlag; +} + +export function mapCagOptions(opts: CagOption[] | undefined): ClidocOption[] { + if (!opts) return []; + return opts.map((o) => ({ + flags: buildCagFlags(o), + long: `--${o.long}`, + short: o.short ? `-${o.short}` : undefined, + description: o.help ?? '', + type: o.value_name ? 'string' : 'boolean', + required: o.required, + defaultValue: o.default, + allowedValues: undefined, + })); +} + +function addCagCommand( + cmd: CagCommand, + parentId: string, + parentFullName: string, + parentDepth: number, + allCommands: ClidocCommand[], +): void { + const fullName = `${parentFullName} ${cmd.name}`; + const id = fullName.replaceAll(/\s+/g, '-'); + const hasChildren = (cmd.subcommands?.length ?? 0) > 0; + + const entry: ClidocCommand = { + id, + name: cmd.name, + fullName, + description: cmd.about ?? '', + isGroup: hasChildren, + isRoot: false, + requiresAuth: !CAG_NON_AUTH_FULL_NAMES.has(fullName), + depth: parentDepth + 1, + parentId, + arguments: [], + options: mapCagOptions(cmd.options), + examples: EXAMPLES[fullName] ?? [], + children: (cmd.subcommands ?? []).map((c) => `${id}-${c.name}`), + }; + + allCommands.push(entry); + + for (const child of cmd.subcommands ?? []) { + addCagCommand(child, id, fullName, parentDepth + 1, allCommands); + } +} + +/** + * Stitch the CAG command tree into `allCommands` under the `sonar-context` entry. + * + * - Sets `sonar-context.isGroup = true` and replaces its passthrough `arguments`. + * - Recursively adds a `ClidocCommand` for every visible CAG subcommand. + * - Auth flags follow `CAG_NON_AUTH_FULL_NAMES` — everything else is `requiresAuth: true`. + * + * Throws if the `sonar-context` entry is missing from `allCommands`. + */ +export function mergeCagTree(cagTree: CagCliTree, allCommands: ClidocCommand[]): void { + const contextEntry = allCommands.find((c) => c.id === 'sonar-context'); + if (!contextEntry) { + throw new Error('sonar-context entry not found in allCommands — cannot merge CAG tree'); + } + + contextEntry.isGroup = true; + contextEntry.arguments = []; + contextEntry.children = cagTree.subcommands.map((c) => `sonar-context-${c.name}`); + + for (const cmd of cagTree.subcommands) { + addCagCommand(cmd, 'sonar-context', 'sonar context', 1, allCommands); + } +} diff --git a/tests/fixtures/cag-cli-tree.json b/tests/fixtures/cag-cli-tree.json new file mode 100644 index 000000000..bd9264466 --- /dev/null +++ b/tests/fixtures/cag-cli-tree.json @@ -0,0 +1,547 @@ +{ + "version": "0.14.0-dev", + "subcommands": [ + { + "name": "tool", + "about": "Management commands for setup, daemon lifecycle, and skill installation", + "subcommands": [ + { + "name": "integrate", + "about": "Integrate this workspace and start daemon", + "options": [ + { + "long": "workspace", + "value_name": "WORKSPACE", + "help": "Workspace root path", + "required": false + }, + { + "long": "json", + "value_name": "JSON", + "help": "Output as JSON to stdout", + "required": false + }, + { + "long": "invocation-prefix", + "value_name": "PREFIX", + "help": "Override the binary invocation referenced in user-facing hints (default: 'sonar-context-augmentation'). Use 'sonar context' when invoking via the sonarqube-cli wrapper", + "required": false, + "default": "sonar-context-augmentation" + } + ] + }, + { + "name": "start", + "about": "Start the daemon using the current workspace configuration" + }, + { + "name": "status", + "about": "Show running daemon status", + "options": [ + { + "long": "json", + "value_name": "JSON", + "help": "Output as JSON", + "required": false + }, + { + "long": "cwd", + "value_name": "CWD", + "help": "Show status for daemon matching this workspace", + "required": false + }, + { + "long": "project-key", + "value_name": "PROJECT_KEY", + "help": "Show status for daemon matching this project key", + "required": false + } + ] + }, + { + "name": "stop", + "about": "Stop a running daemon", + "options": [ + { + "long": "project-key", + "value_name": "PROJECT_KEY", + "help": "Stop daemon by project key", + "required": false + }, + { + "long": "cwd", + "value_name": "CWD", + "help": "Stop daemon matching workspace path", + "required": false + }, + { + "long": "pid", + "value_name": "PID", + "help": "Stop daemon by PID", + "required": false + }, + { + "long": "all", + "value_name": "ALL", + "help": "Stop all daemons", + "required": false + }, + { + "long": "json", + "value_name": "JSON", + "help": "Output as JSON to stdout", + "required": false + } + ] + }, + { + "name": "print-skill", + "about": "Print the rendered agent skill (SKILL.md) to stdout", + "options": [ + { + "long": "invocation-prefix", + "value_name": "PREFIX", + "help": "Override the binary invocation referenced inside the rendered SKILL.md (default: 'sonar-context-augmentation'). Use 'sonar context' when invoking via the sonarqube-cli wrapper", + "required": false, + "default": "sonar-context-augmentation" + }, + { + "long": "sca-enabled", + "value_name": "BOOL", + "help": "Whether SCA tools should be advertised in the rendered SKILL.md", + "required": true + } + ] + } + ] + }, + { + "name": "guidelines", + "about": "MUST call before writing or editing source code", + "subcommands": [ + { + "name": "get", + "about": "Get coding guidelines. Output is markdown text", + "options": [ + { + "long": "categories", + "value_name": "CATEGORIES", + "help": "Categories to retrieve. Accepts one or more values: --categories \"Auth & Identity\" \"Exception & Error Handling\" or repeated: --categories \"Auth & Identity\" --categories \"Exception & Error Handling\"", + "required": false, + "num_args": "1+" + }, + { + "long": "languages", + "value_name": "LANGUAGES", + "help": "Target languages (SonarQube key format, e.g. java, typescript, python). Accepts one or more values: --languages java python or repeated: --languages java --languages python", + "required": false, + "num_args": "1+" + }, + { + "long": "mode", + "value_name": "MODE", + "help": "Retrieval mode", + "required": false + }, + { + "long": "files", + "value_name": "FILES", + "help": "File paths to filter by. Accepts one or more values: --files a.rs b.rs", + "required": false, + "num_args": "1+" + } + ] + } + ] + }, + { + "name": "dependencies", + "about": "MUST call before adding or upgrading any third-party package", + "subcommands": [ + { + "name": "check", + "about": "Check a dependency for vulnerabilities, malware, and license compliance", + "options": [ + { + "long": "purl", + "value_name": "PURL", + "help": "Package URL with version (e.g. \"pkg:npm/lodash@4.17.21\")", + "required": true + }, + { + "long": "format", + "value_name": "FORMAT", + "help": "Output format: compact (default) or pretty", + "required": false + } + ] + } + ] + }, + { + "name": "architecture", + "about": "Use for code understanding, planning, or cross-module work", + "subcommands": [ + { + "name": "get-current", + "about": "Get the current architecture graph", + "options": [ + { + "long": "ecosystem", + "value_name": "ECOSYSTEM", + "help": "Ecosystem filter (java, cs, py, js, ts)", + "required": false + }, + { + "long": "depth", + "value_name": "DEPTH", + "help": "Hierarchy depth (0=root only, 1=root+children, etc., max 50)", + "required": false, + "default": "3" + }, + { + "long": "path-prefix", + "value_name": "PATH_PREFIX", + "help": "Path prefix to filter nodes (e.g. 'com.example.service')", + "required": false + }, + { + "long": "fields", + "value_name": "FIELDS", + "help": "Fields to include in response", + "required": false + }, + { + "long": "format", + "value_name": "FORMAT", + "help": "Output format: compact (default) or pretty", + "required": false + } + ] + }, + { + "name": "get-intended", + "about": "Get intended architecture constraints", + "options": [ + { + "long": "fields", + "value_name": "FIELDS", + "help": "Fields to include in response", + "required": false + }, + { + "long": "format", + "value_name": "FORMAT", + "help": "Output format: compact (default) or pretty", + "required": false + } + ] + } + ] + }, + { + "name": "navigation", + "about": "Use instead of grep or file reading when finding, reading, or tracing code", + "subcommands": [ + { + "name": "search-signatures", + "about": "Search code by signature patterns", + "options": [ + { + "long": "pattern", + "value_name": "PATTERNS", + "help": "Regex patterns to match in signatures", + "required": true + }, + { + "long": "exclude-pattern", + "value_name": "EXCLUDE_PATTERNS", + "help": "Exclude regex patterns", + "required": false + }, + { + "long": "include-glob", + "value_name": "INCLUDE_GLOB", + "help": "File path glob pattern to include", + "required": false + }, + { + "long": "exclude-glob", + "value_name": "EXCLUDE_GLOB", + "help": "File path glob pattern to exclude", + "required": false + }, + { + "long": "fields", + "value_name": "FIELDS", + "help": "Comma-separated fields to include: end_column, end_line, file_path, fqn, item_type, signature, start_column, start_line", + "required": false + }, + { + "long": "limit", + "value_name": "LIMIT", + "help": "Maximum results (1-10000)", + "required": false, + "default": "10" + }, + { + "long": "output", + "value_name": "OUTPUT", + "help": "Output mode: json, fqns, or names", + "required": false + }, + { + "long": "format", + "value_name": "FORMAT", + "help": "Output format: compact (default) or pretty", + "required": false + } + ] + }, + { + "name": "search-bodies", + "about": "Search code by body patterns", + "options": [ + { + "long": "pattern", + "value_name": "PATTERNS", + "help": "Regex patterns to match in code bodies", + "required": true + }, + { + "long": "exclude-pattern", + "value_name": "EXCLUDE_PATTERNS", + "help": "Exclude regex patterns", + "required": false + }, + { + "long": "include-glob", + "value_name": "INCLUDE_GLOB", + "help": "File path glob pattern to include", + "required": false + }, + { + "long": "exclude-glob", + "value_name": "EXCLUDE_GLOB", + "help": "File path glob pattern to exclude", + "required": false + }, + { + "long": "fields", + "value_name": "FIELDS", + "help": "Comma-separated fields to include: end_column, end_line, file_path, fqn, item_type, signature, start_column, start_line", + "required": false + }, + { + "long": "limit", + "value_name": "LIMIT", + "help": "Maximum results (1-10000)", + "required": false, + "default": "10" + }, + { + "long": "output", + "value_name": "OUTPUT", + "help": "Output mode: json, fqns, or names", + "required": false + }, + { + "long": "format", + "value_name": "FORMAT", + "help": "Output format: compact (default) or pretty", + "required": false + } + ] + }, + { + "name": "get-source", + "about": "Get source code for a fully qualified name", + "options": [ + { + "long": "fqn", + "value_name": "FQN", + "help": "Fully qualified name", + "required": false + }, + { + "long": "fqn-stdin", + "value_name": "FQN_STDIN", + "help": "Read FQNs from stdin (one per line)", + "required": false + }, + { + "long": "fields", + "value_name": "FIELDS", + "help": "Comma-separated fields to include: body, end_column, end_line, signature, start_column, start_line, structure_type", + "required": false + }, + { + "long": "format", + "value_name": "FORMAT", + "help": "Output format: compact (default) or pretty", + "required": false + } + ] + }, + { + "name": "trace-callers", + "about": "Trace upstream call flow", + "options": [ + { + "long": "fqn", + "value_name": "FQN", + "help": "Fully qualified name", + "required": false + }, + { + "long": "fqn-stdin", + "value_name": "FQN_STDIN", + "help": "Read FQNs from stdin (one per line)", + "required": false + }, + { + "long": "depth", + "value_name": "DEPTH", + "help": "Call chain depth (0-50)", + "required": false, + "default": "1" + }, + { + "long": "fields", + "value_name": "FIELDS", + "help": "Comma-separated fields to include: calls, depth, direction, end_column, end_line, file_path, fqn, signature, start_column, start_line", + "required": false + }, + { + "long": "output", + "value_name": "OUTPUT", + "help": "Output mode: json, fqns, or names", + "required": false + }, + { + "long": "format", + "value_name": "FORMAT", + "help": "Output format: compact (default) or pretty", + "required": false + } + ] + }, + { + "name": "trace-callees", + "about": "Trace downstream call flow", + "options": [ + { + "long": "fqn", + "value_name": "FQN", + "help": "Fully qualified name", + "required": false + }, + { + "long": "fqn-stdin", + "value_name": "FQN_STDIN", + "help": "Read FQNs from stdin (one per line)", + "required": false + }, + { + "long": "depth", + "value_name": "DEPTH", + "help": "Call chain depth (0-50)", + "required": false, + "default": "1" + }, + { + "long": "fields", + "value_name": "FIELDS", + "help": "Comma-separated fields to include: calls, depth, direction, end_column, end_line, file_path, fqn, signature, start_column, start_line", + "required": false + }, + { + "long": "output", + "value_name": "OUTPUT", + "help": "Output mode: json, fqns, or names", + "required": false + }, + { + "long": "format", + "value_name": "FORMAT", + "help": "Output format: compact (default) or pretty", + "required": false + } + ] + }, + { + "name": "get-type-hierarchy", + "about": "Get type hierarchy for a fully qualified name", + "options": [ + { + "long": "fqn", + "value_name": "FQN", + "help": "Fully qualified name", + "required": false + }, + { + "long": "fqn-stdin", + "value_name": "FQN_STDIN", + "help": "Read FQNs from stdin (one per line)", + "required": false + }, + { + "long": "fields", + "value_name": "FIELDS", + "help": "Comma-separated fields to include: children, dependency_kind, depth, file_path, fqn, parents", + "required": false + }, + { + "long": "output", + "value_name": "OUTPUT", + "help": "Output mode: json, fqns, or names", + "required": false + }, + { + "long": "format", + "value_name": "FORMAT", + "help": "Output format: compact (default) or pretty", + "required": false + } + ] + }, + { + "name": "get-references", + "about": "Get references for a fully qualified name", + "options": [ + { + "long": "fqn", + "value_name": "FQN", + "help": "Fully qualified name", + "required": false + }, + { + "long": "fqn-stdin", + "value_name": "FQN_STDIN", + "help": "Read FQNs from stdin (one per line)", + "required": false + }, + { + "long": "fields", + "value_name": "FIELDS", + "help": "Comma-separated fields to include: dependency_kinds, file_path, fqn", + "required": false + }, + { + "long": "output", + "value_name": "OUTPUT", + "help": "Output mode: json, fqns, or names", + "required": false + }, + { + "long": "format", + "value_name": "FORMAT", + "help": "Output format: compact (default) or pretty", + "required": false + } + ] + } + ] + } + ] +} diff --git a/tests/unit/build-scripts/docs/merge-cag-tree.test.ts b/tests/unit/build-scripts/docs/merge-cag-tree.test.ts new file mode 100644 index 000000000..545ce686b --- /dev/null +++ b/tests/unit/build-scripts/docs/merge-cag-tree.test.ts @@ -0,0 +1,206 @@ +/* + * SonarQube CLI + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { + buildCagFlags, + CAG_NON_AUTH_FULL_NAMES, + type CagCliTree, + type ClidocCommand, + mergeCagTree, +} from '../../../../build-scripts/docs/merge-cag-tree'; + +const fixtureTree: CagCliTree = JSON.parse( + readFileSync(join(import.meta.dir, '..', '..', '..', 'fixtures', 'cag-cli-tree.json'), 'utf-8'), +); + +function makeContextEntry(): ClidocCommand { + return { + id: 'sonar-context', + name: 'context', + fullName: 'sonar context', + description: 'Context augmentation passthrough', + isGroup: false, + isRoot: false, + requiresAuth: false, + depth: 1, + parentId: 'sonar', + arguments: [ + { name: 'action', description: '', required: false, variadic: false }, + { name: 'args', description: '', required: false, variadic: true }, + ], + options: [], + examples: [], + children: [], + }; +} + +function runMerge(): { allCommands: ClidocCommand[]; byId: Map } { + const contextEntry = makeContextEntry(); + const allCommands: ClidocCommand[] = [contextEntry]; + mergeCagTree(fixtureTree, allCommands); + const byId = new Map(allCommands.map((c) => [c.id, c])); + return { allCommands, byId }; +} + +describe('mergeCagTree', () => { + it('sets sonar-context isGroup to true', () => { + const { byId } = runMerge(); + expect(byId.get('sonar-context')!.isGroup).toBe(true); + }); + + it('clears the passthrough positional arguments', () => { + const { byId } = runMerge(); + expect(byId.get('sonar-context')!.arguments).toHaveLength(0); + }); + + it('sets sonar-context children to top-level CAG group IDs', () => { + const { byId } = runMerge(); + const children = byId.get('sonar-context')!.children; + expect(children).toContain('sonar-context-tool'); + expect(children).toContain('sonar-context-guidelines'); + expect(children).toContain('sonar-context-dependencies'); + expect(children).toContain('sonar-context-architecture'); + expect(children).toContain('sonar-context-navigation'); + }); + + it('adds group entries for each top-level CAG command', () => { + const { byId } = runMerge(); + expect(byId.has('sonar-context-tool')).toBe(true); + expect(byId.has('sonar-context-guidelines')).toBe(true); + expect(byId.has('sonar-context-navigation')).toBe(true); + }); + + it('marks groups as isGroup: true and leaf commands as isGroup: false', () => { + const { byId } = runMerge(); + expect(byId.get('sonar-context-guidelines')!.isGroup).toBe(true); + expect(byId.get('sonar-context-guidelines-get')!.isGroup).toBe(false); + }); + + it('assigns correct depth relative to sonar-context', () => { + const { byId } = runMerge(); + // sonar-context is depth 1, so its CAG children are depth 2 + expect(byId.get('sonar-context-tool')!.depth).toBe(2); + // leaf commands are depth 3 + expect(byId.get('sonar-context-guidelines-get')!.depth).toBe(3); + expect(byId.get('sonar-context-tool-start')!.depth).toBe(3); + }); + + it('assigns correct parentId to CAG subcommands', () => { + const { byId } = runMerge(); + expect(byId.get('sonar-context-tool')!.parentId).toBe('sonar-context'); + expect(byId.get('sonar-context-tool-integrate')!.parentId).toBe('sonar-context-tool'); + expect(byId.get('sonar-context-guidelines-get')!.parentId).toBe('sonar-context-guidelines'); + }); + + it('marks non-auth lifecycle commands as requiresAuth: false', () => { + const { byId } = runMerge(); + for (const id of [ + 'sonar-context-tool-start', + 'sonar-context-tool-stop', + 'sonar-context-tool-status', + 'sonar-context-tool-print-skill', + ]) { + expect(byId.get(id)!.requiresAuth).toBe(false); + } + }); + + it('marks all other CAG commands as requiresAuth: true', () => { + const { byId } = runMerge(); + for (const id of [ + 'sonar-context-guidelines-get', + 'sonar-context-dependencies-check', + 'sonar-context-architecture-get-current', + 'sonar-context-navigation-search-signatures', + 'sonar-context-tool-integrate', + ]) { + expect(byId.get(id)!.requiresAuth).toBe(true); + } + }); + + it('maps CAG options to ClidocOption shape', () => { + const { byId } = runMerge(); + const getCmd = byId.get('sonar-context-guidelines-get')!; + expect(getCmd.options.length).toBeGreaterThan(0); + const categoriesOpt = getCmd.options.find((o) => o.long === '--categories'); + expect(categoriesOpt).toBeDefined(); + expect(categoriesOpt!.type).toBe('string'); + expect(categoriesOpt!.required).toBe(false); + }); + + it('sets fullName to the space-joined path', () => { + const { byId } = runMerge(); + expect(byId.get('sonar-context-navigation-search-signatures')!.fullName).toBe( + 'sonar context navigation search-signatures', + ); + }); + + it('throws when sonar-context entry is missing from allCommands', () => { + expect(() => mergeCagTree(fixtureTree, [])).toThrow('sonar-context entry not found'); + }); + + it('adds all expected navigation subcommands', () => { + const { byId } = runMerge(); + const expected = [ + 'sonar-context-navigation-search-signatures', + 'sonar-context-navigation-search-bodies', + 'sonar-context-navigation-get-source', + 'sonar-context-navigation-trace-callers', + 'sonar-context-navigation-trace-callees', + 'sonar-context-navigation-get-type-hierarchy', + 'sonar-context-navigation-get-references', + ]; + for (const id of expected) { + expect(byId.has(id)).toBe(true); + } + }); +}); + +describe('buildCagFlags', () => { + it('formats a long-only flag with value', () => { + expect(buildCagFlags({ long: 'pattern', value_name: 'PATTERN', required: true })).toBe( + '--pattern ', + ); + }); + + it('formats a boolean flag (no value_name)', () => { + expect(buildCagFlags({ long: 'json', required: false })).toBe('--json'); + }); + + it('formats a flag with short alias', () => { + expect(buildCagFlags({ long: 'verbose', short: 'v', required: false })).toBe('-v, --verbose'); + }); +}); + +describe('CAG_NON_AUTH_FULL_NAMES', () => { + it('contains the four daemon lifecycle commands', () => { + expect(CAG_NON_AUTH_FULL_NAMES.has('sonar context tool start')).toBe(true); + expect(CAG_NON_AUTH_FULL_NAMES.has('sonar context tool stop')).toBe(true); + expect(CAG_NON_AUTH_FULL_NAMES.has('sonar context tool status')).toBe(true); + expect(CAG_NON_AUTH_FULL_NAMES.has('sonar context tool print-skill')).toBe(true); + }); + + it('does not contain commands that require SonarQube access', () => { + expect(CAG_NON_AUTH_FULL_NAMES.has('sonar context guidelines get')).toBe(false); + expect(CAG_NON_AUTH_FULL_NAMES.has('sonar context tool integrate')).toBe(false); + }); +}); From ac7741dce79f12dc3c95dfc1bed37ecbb5a62553 Mon Sep 17 00:00:00 2001 From: Massimo Paladin <456050+mpaladin@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:22:33 +0200 Subject: [PATCH 2/5] fix/mp/CLI-613 Fix Prettier formatting in dump-cag-tree.ts --- build-scripts/docs/dump-cag-tree.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build-scripts/docs/dump-cag-tree.ts b/build-scripts/docs/dump-cag-tree.ts index fdfae53cf..4ee47b76b 100644 --- a/build-scripts/docs/dump-cag-tree.ts +++ b/build-scripts/docs/dump-cag-tree.ts @@ -27,7 +27,7 @@ import { spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; -import { chmod,readFile } from 'node:fs/promises'; +import { chmod, readFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; From eda2a9613e659b08be64d6f5c0e5d0ea67648181 Mon Sep 17 00:00:00 2001 From: Massimo Paladin <456050+mpaladin@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:36:03 +0200 Subject: [PATCH 3/5] fix/mp/CLI-613 Harden CAG tree dump + render booleans/variadics correctly Address Gitar review: - Clean up downloaded .tar.gz/.asc in a `finally` block so failed verifications don't leave unverified archives behind and successful runs don't waste cache. - Guard `JSON.parse` of the binary's stdout with an actionable error that includes stdout/stderr snippets. - Treat absence of `value_name` as the boolean-flag signal (CAG strips it upstream). Render variadic options (`num_args: "+"`) with a trailing ellipsis (e.g. `--categories ...`). - Refresh fixture from updated CAG dump (boolean flags no longer carry value_name) and add regression tests for boolean/variadic rendering. --- build-scripts/docs/dump-cag-tree.ts | 94 +++++++++++-------- build-scripts/docs/merge-cag-tree.ts | 28 +++++- tests/fixtures/cag-cli-tree.json | 36 +++---- .../build-scripts/docs/merge-cag-tree.test.ts | 27 ++++++ 4 files changed, 128 insertions(+), 57 deletions(-) diff --git a/build-scripts/docs/dump-cag-tree.ts b/build-scripts/docs/dump-cag-tree.ts index 4ee47b76b..b95204d94 100644 --- a/build-scripts/docs/dump-cag-tree.ts +++ b/build-scripts/docs/dump-cag-tree.ts @@ -26,7 +26,7 @@ */ import { spawnSync } from 'node:child_process'; -import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { chmod, readFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -96,42 +96,51 @@ async function resolveCagBinary(): Promise { const ascPath = `${archivePath}.asc`; console.log(` Downloading sonar-context-augmentation ${version} for ${platSuffix}…`); - await downloadBinary(archiveUrl, archivePath); - await downloadBinary(`${archiveUrl}.asc`, ascPath); - - const [archiveBytes, armoredSignature] = await Promise.all([ - readFile(archivePath), - readFile(ascPath, 'utf-8'), - ]); - - const expected = SONAR_CONTEXT_AUGMENTATION_SIGNATURES[platSuffix]; - if (!expected) { - throw new Error( - `No pinned signature for sonar-context-augmentation on ${platSuffix}. ` + - `Run \`bun run fetch:signatures\` to refresh.`, - ); - } - if (expected !== armoredSignature.trim()) { - throw new Error( - `Signature mismatch for sonar-context-augmentation on ${platSuffix}: ` + - `the downloaded .asc does not match the pinned signature.`, + try { + await downloadBinary(archiveUrl, archivePath); + await downloadBinary(`${archiveUrl}.asc`, ascPath); + + const [archiveBytes, armoredSignature] = await Promise.all([ + readFile(archivePath), + readFile(ascPath, 'utf-8'), + ]); + + const expected = SONAR_CONTEXT_AUGMENTATION_SIGNATURES[platSuffix]; + if (!expected) { + throw new Error( + `No pinned signature for sonar-context-augmentation on ${platSuffix}. ` + + `Run \`bun run fetch:signatures\` to refresh.`, + ); + } + if (expected !== armoredSignature.trim()) { + throw new Error( + `Signature mismatch for sonar-context-augmentation on ${platSuffix}: ` + + `the downloaded .asc does not match the pinned signature.`, + ); + } + await verifyPgpSignature(archiveBytes, armoredSignature, SONARSOURCE_PUBLIC_KEY); + + const binaryBytes = extractFileFromTarGz( + archiveBytes, + `sonar-context-augmentation${platform.extension}`, ); - } - await verifyPgpSignature(archiveBytes, armoredSignature, SONARSOURCE_PUBLIC_KEY); - - const binaryBytes = extractFileFromTarGz( - archiveBytes, - `sonar-context-augmentation${platform.extension}`, - ); - if (!binaryBytes) { - throw new Error( - `sonar-context-augmentation binary not found inside ${archiveUrl.split('/').at(-1)}.`, - ); - } - - writeFileSync(binaryPath, binaryBytes); - if (platform.os !== 'windows') { - await chmod(binaryPath, 0o755); + if (!binaryBytes) { + throw new Error( + `sonar-context-augmentation binary not found inside ${archiveUrl.split('/').at(-1)}.`, + ); + } + + writeFileSync(binaryPath, binaryBytes); + if (platform.os !== 'windows') { + await chmod(binaryPath, 0o755); + } + } finally { + // Remove the downloaded archive and signature whether verification succeeded or + // not — failed runs shouldn't leave unverified .tar.gz behind, successful runs + // don't need them once the binary is extracted. Mirrors the install path in + // src/cli/commands/_common/install/context-augmentation.ts. + rmSync(archivePath, { force: true }); + rmSync(ascPath, { force: true }); } return binaryPath; @@ -169,5 +178,16 @@ export async function dumpCagTree(): Promise { ); } - return JSON.parse(result.stdout) as CagCliTree; + try { + return JSON.parse(result.stdout) as CagCliTree; + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + const stdoutSnippet = result.stdout.slice(0, 200); + const stderrSnippet = (result.stderr ?? '').slice(0, 200); + throw new Error( + `Failed to parse sonar-context-augmentation tool dump-cli-tree output as JSON: ${reason}\n` + + `stdout (first 200 chars): ${stdoutSnippet}\n` + + `stderr (first 200 chars): ${stderrSnippet}`, + ); + } } diff --git a/build-scripts/docs/merge-cag-tree.ts b/build-scripts/docs/merge-cag-tree.ts index 741588b6a..ab464e7a6 100644 --- a/build-scripts/docs/merge-cag-tree.ts +++ b/build-scripts/docs/merge-cag-tree.ts @@ -72,8 +72,32 @@ export const CAG_NON_AUTH_FULL_NAMES = new Set([ 'sonar context tool print-skill', ]); +/** + * Returns true when the CAG option accepts at least one value. + * + * CAG strips `value_name` for boolean (zero-arg) flags before emitting the dump, so the + * absence of `value_name` is the canonical signal. We treat `num_args` as a secondary + * signal for forward compatibility — e.g. a future build that re-introduces value_name + * on boolean flags would still report `num_args: "0..=0"` or similar. + */ +function takesValue(opt: CagOption): boolean { + if (!opt.value_name) return false; + if (opt.num_args && /^0(?![+0-9])/.test(opt.num_args)) return false; + return true; +} + +/** + * CAG emits `num_args: "+"` (e.g. "1+") for variadic options that accept any number + * of values, and either `null` or a fixed range like `"2..=3"` otherwise. Treat any "+" + * suffix as variadic so docs render `--categories ...` rather than as a + * single-value option. + */ +function isVariadic(opt: CagOption): boolean { + return typeof opt.num_args === 'string' && opt.num_args.endsWith('+'); +} + export function buildCagFlags(opt: CagOption): string { - const valuePart = opt.value_name ? ` <${opt.value_name}>` : ''; + const valuePart = takesValue(opt) ? ` <${opt.value_name}>${isVariadic(opt) ? '...' : ''}` : ''; const longFlag = `--${opt.long}${valuePart}`; return opt.short ? `-${opt.short}, ${longFlag}` : longFlag; } @@ -85,7 +109,7 @@ export function mapCagOptions(opts: CagOption[] | undefined): ClidocOption[] { long: `--${o.long}`, short: o.short ? `-${o.short}` : undefined, description: o.help ?? '', - type: o.value_name ? 'string' : 'boolean', + type: takesValue(o) ? 'string' : 'boolean', required: o.required, defaultValue: o.default, allowedValues: undefined, diff --git a/tests/fixtures/cag-cli-tree.json b/tests/fixtures/cag-cli-tree.json index bd9264466..9e8af5554 100644 --- a/tests/fixtures/cag-cli-tree.json +++ b/tests/fixtures/cag-cli-tree.json @@ -17,9 +17,9 @@ }, { "long": "json", - "value_name": "JSON", "help": "Output as JSON to stdout", - "required": false + "required": false, + "default": "false" }, { "long": "invocation-prefix", @@ -40,9 +40,9 @@ "options": [ { "long": "json", - "value_name": "JSON", "help": "Output as JSON", - "required": false + "required": false, + "default": "false" }, { "long": "cwd", @@ -82,15 +82,15 @@ }, { "long": "all", - "value_name": "ALL", "help": "Stop all daemons", - "required": false + "required": false, + "default": "false" }, { "long": "json", - "value_name": "JSON", "help": "Output as JSON to stdout", - "required": false + "required": false, + "default": "false" } ] }, @@ -365,9 +365,9 @@ }, { "long": "fqn-stdin", - "value_name": "FQN_STDIN", "help": "Read FQNs from stdin (one per line)", - "required": false + "required": false, + "default": "false" }, { "long": "fields", @@ -395,9 +395,9 @@ }, { "long": "fqn-stdin", - "value_name": "FQN_STDIN", "help": "Read FQNs from stdin (one per line)", - "required": false + "required": false, + "default": "false" }, { "long": "depth", @@ -438,9 +438,9 @@ }, { "long": "fqn-stdin", - "value_name": "FQN_STDIN", "help": "Read FQNs from stdin (one per line)", - "required": false + "required": false, + "default": "false" }, { "long": "depth", @@ -481,9 +481,9 @@ }, { "long": "fqn-stdin", - "value_name": "FQN_STDIN", "help": "Read FQNs from stdin (one per line)", - "required": false + "required": false, + "default": "false" }, { "long": "fields", @@ -517,9 +517,9 @@ }, { "long": "fqn-stdin", - "value_name": "FQN_STDIN", "help": "Read FQNs from stdin (one per line)", - "required": false + "required": false, + "default": "false" }, { "long": "fields", diff --git a/tests/unit/build-scripts/docs/merge-cag-tree.test.ts b/tests/unit/build-scripts/docs/merge-cag-tree.test.ts index 545ce686b..be1b87d69 100644 --- a/tests/unit/build-scripts/docs/merge-cag-tree.test.ts +++ b/tests/unit/build-scripts/docs/merge-cag-tree.test.ts @@ -26,6 +26,7 @@ import { CAG_NON_AUTH_FULL_NAMES, type CagCliTree, type ClidocCommand, + mapCagOptions, mergeCagTree, } from '../../../../build-scripts/docs/merge-cag-tree'; @@ -189,6 +190,32 @@ describe('buildCagFlags', () => { it('formats a flag with short alias', () => { expect(buildCagFlags({ long: 'verbose', short: 'v', required: false })).toBe('-v, --verbose'); }); + + it('marks variadic options with an ellipsis', () => { + expect( + buildCagFlags({ + long: 'categories', + value_name: 'CATEGORIES', + num_args: '1+', + required: false, + }), + ).toBe('--categories ...'); + }); +}); + +describe('mapCagOptions', () => { + it('reports boolean type when value_name is absent (real fixture --json)', () => { + const opts = mapCagOptions([{ long: 'json', required: false, default: 'false' }]); + expect(opts).toHaveLength(1); + expect(opts[0].type).toBe('boolean'); + expect(opts[0].flags).toBe('--json'); + }); + + it('reports string type for value-taking options', () => { + const opts = mapCagOptions([{ long: 'workspace', value_name: 'WORKSPACE', required: false }]); + expect(opts[0].type).toBe('string'); + expect(opts[0].flags).toBe('--workspace '); + }); }); describe('CAG_NON_AUTH_FULL_NAMES', () => { From 59d6239538c70d000c5ea3f14995e54d86589183 Mon Sep 17 00:00:00 2001 From: Massimo Paladin <456050+mpaladin@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:39:52 +0200 Subject: [PATCH 4/5] fix/mp/CLI-613 Import describe/it/expect from bun:test for typecheck --- tests/unit/build-scripts/docs/merge-cag-tree.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit/build-scripts/docs/merge-cag-tree.test.ts b/tests/unit/build-scripts/docs/merge-cag-tree.test.ts index be1b87d69..5bd6416fd 100644 --- a/tests/unit/build-scripts/docs/merge-cag-tree.test.ts +++ b/tests/unit/build-scripts/docs/merge-cag-tree.test.ts @@ -21,6 +21,8 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; +import { describe, expect, it } from 'bun:test'; + import { buildCagFlags, CAG_NON_AUTH_FULL_NAMES, From 0b824d96d75fb0cc069ed81c1359c320a8ff93b6 Mon Sep 17 00:00:00 2001 From: Massimo Paladin <456050+mpaladin@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:43:55 +0200 Subject: [PATCH 5/5] fix/mp/CLI-613 Drop defaultValue on boolean flags Address Gitar suggestion: CAG emits `default: "false"` for boolean flags that initialize to false. Surfacing this as `defaultValue` in the generated docs would advertise a stringified "false" as a meaningful default. Only carry defaultValue forward when the option actually accepts a value. --- build-scripts/docs/merge-cag-tree.ts | 26 ++++++++++++------- .../build-scripts/docs/merge-cag-tree.test.ts | 12 +++++++++ 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/build-scripts/docs/merge-cag-tree.ts b/build-scripts/docs/merge-cag-tree.ts index ab464e7a6..4488fe355 100644 --- a/build-scripts/docs/merge-cag-tree.ts +++ b/build-scripts/docs/merge-cag-tree.ts @@ -104,16 +104,22 @@ export function buildCagFlags(opt: CagOption): string { export function mapCagOptions(opts: CagOption[] | undefined): ClidocOption[] { if (!opts) return []; - return opts.map((o) => ({ - flags: buildCagFlags(o), - long: `--${o.long}`, - short: o.short ? `-${o.short}` : undefined, - description: o.help ?? '', - type: takesValue(o) ? 'string' : 'boolean', - required: o.required, - defaultValue: o.default, - allowedValues: undefined, - })); + return opts.map((o) => { + const hasValue = takesValue(o); + return { + flags: buildCagFlags(o), + long: `--${o.long}`, + short: o.short ? `-${o.short}` : undefined, + description: o.help ?? '', + type: hasValue ? 'string' : 'boolean', + required: o.required, + // CAG emits `default: "false"` for boolean flags that initialize to false; surface + // a defaultValue only for value-taking options to avoid documenting a stringified + // "false" as if it were a meaningful default for a boolean switch. + defaultValue: hasValue ? o.default : undefined, + allowedValues: undefined, + }; + }); } function addCagCommand( diff --git a/tests/unit/build-scripts/docs/merge-cag-tree.test.ts b/tests/unit/build-scripts/docs/merge-cag-tree.test.ts index 5bd6416fd..bd88885ee 100644 --- a/tests/unit/build-scripts/docs/merge-cag-tree.test.ts +++ b/tests/unit/build-scripts/docs/merge-cag-tree.test.ts @@ -213,6 +213,18 @@ describe('mapCagOptions', () => { expect(opts[0].flags).toBe('--json'); }); + it('omits defaultValue on boolean flags so docs do not show a stringified "false"', () => { + const opts = mapCagOptions([{ long: 'json', required: false, default: 'false' }]); + expect(opts[0].defaultValue).toBeUndefined(); + }); + + it('preserves defaultValue on value-taking options', () => { + const opts = mapCagOptions([ + { long: 'depth', value_name: 'DEPTH', required: false, default: '3' }, + ]); + expect(opts[0].defaultValue).toBe('3'); + }); + it('reports string type for value-taking options', () => { const opts = mapCagOptions([{ long: 'workspace', value_name: 'WORKSPACE', required: false }]); expect(opts[0].type).toBe('string');