From 07f92a3fcf7d40a2ac23be1e99ee796407ca49c7 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:46:14 +0000 Subject: [PATCH 01/13] ref(cli): Parse arity-dependent arguments in the scanner Follow-up to #1655 for #1564. Positionals whose meaning depends on how many arguments were given (`[/] `, `log list`'s trailing trace ID) kept parsing in `func`, because a per-element Stricli `parse` can't see the count. Our `@stricli/core` patch now lets an array positional declare `combine`: it gets every parsed element and returns the one value the command receives. A throw becomes an `ArgumentParseError`, so these commands fail in the scanner with exit 252 like other arguments. `boundArguments` (`src/lib/positional-parse.ts`) builds such a positional: a pure `parse` over all the arguments, bound to a `resolve` that runs after the auth guard, like `boundPositional`. `log list`, `replay view` and `replay download` move onto it. Parsing a pasted Sentry URL no longer applies its server; the parse returns `baseUrl` (and trace-target warnings), and `resolve` applies them. `parseTraceTargetInput` and `parseDualModeInput` are the side-effect-free trace parsers; the existing ones wrap them, so span and trace commands are unchanged and can migrate onto the same primitive next. A parse error next to a `help` argument is deferred to `func`, so `sentry replay view help` still shows the command's help. Plural alias interception drops the first argument and parses the rest again. Co-Authored-By: BYK --- packages/cli/AGENTS.md | 19 +- packages/cli/lint-rules/cli-oxlint-plugin.js | 8 +- .../cli/patches/@stricli%2Fcore@1.2.8.patch | 166 ++++++++++++++--- packages/cli/script/check-patches.ts | 19 ++ packages/cli/src/commands/log/list.ts | 134 +++++++++----- packages/cli/src/commands/replay/download.ts | 36 ++-- packages/cli/src/commands/replay/view.ts | 116 +++++++++--- packages/cli/src/lib/command.ts | 16 +- packages/cli/src/lib/list-command.ts | 11 +- packages/cli/src/lib/positional-parse.ts | 142 +++++++++++++-- packages/cli/src/lib/trace-target.ts | 144 ++++++++++++--- packages/cli/test/commands/log/list.test.ts | 14 +- .../cli/test/commands/replay/download.test.ts | 4 +- .../cli/test/commands/replay/view.test.ts | 37 +++- .../cli/test/lib/positional-parse.test.ts | 169 ++++++++++++++++++ packages/cli/test/lib/trace-target.test.ts | 48 +++++ pnpm-lock.yaml | 8 +- 17 files changed, 905 insertions(+), 186 deletions(-) diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 1f8f79fb4..31c7faed7 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -267,9 +267,22 @@ runs on the first `resolved` read, inside `func` after the auth guard. `target.raw` is what the user typed; shared code that handles any positional uses `rawPositional(value)`. `buildCommand` runs these parsers on positionals that arrive as strings or are omitted, so the SDK invoke path and tests that -call `command.loader()` keep passing raw strings. Positionals whose -meaning depends on how many arguments were given, such as -`[//]`, keep parsing in `func`. +call `command.loader()` keep passing raw strings. + +**Arity-dependent positionals:** when what an argument means depends on how +many were given (`[/] `, `log list`'s optional +trailing trace ID), declare the positional with `boundArguments` from +`src/lib/positional-parse.ts` (see `replayArguments` in `replay/view.ts` and +`log list`). It is an array positional whose `parse` sees every argument at +once and whose `resolve` binds like a target positional's: `func` takes one +`BoundArguments` (alias it per command and add the alias to +the lint rule's type list), reads `target.parsed`, and awaits +`target.resolved`. Stricli runs the parse in its scanner through the +`combine` hook our `@stricli/core` patch adds, so errors exit 252 like any +other argument. The same purity rule applies: return a pasted URL's server +(`baseUrl`) and any warnings from the parse, and apply them in `resolve` +(`parseTraceTargetInput` and `parseDualModeInput` in `trace-target.ts` do +this for trace targets). **Shell completion:** Target positionals that take an org, project, issue, or dashboard use a completion-aware parser from `src/lib/completion-target.ts` diff --git a/packages/cli/lint-rules/cli-oxlint-plugin.js b/packages/cli/lint-rules/cli-oxlint-plugin.js index 409dd1f97..ec2034d8f 100644 --- a/packages/cli/lint-rules/cli-oxlint-plugin.js +++ b/packages/cli/lint-rules/cli-oxlint-plugin.js @@ -56,11 +56,17 @@ function readsProperty(node, object, property) { ); } -/** Target positional types from `src/lib/target-positional.ts`. */ +/** + * Target positional types from `src/lib/target-positional.ts`, and + * `boundArguments` positionals (`BoundArguments` and the commands' aliases). + */ const BOUND_TARGET_TYPES = new Set([ "OrgOptionalTarget", "OrgProjectTarget", "ProjectBoundTarget", + "BoundArguments", + "LogListArguments", + "ReplayArguments", ]); function boundTargetParams(fn) { diff --git a/packages/cli/patches/@stricli%2Fcore@1.2.8.patch b/packages/cli/patches/@stricli%2Fcore@1.2.8.patch index c8256f2a8..05044c4a5 100644 --- a/packages/cli/patches/@stricli%2Fcore@1.2.8.patch +++ b/packages/cli/patches/@stricli%2Fcore@1.2.8.patch @@ -1,8 +1,49 @@ diff --git a/dist/index.cjs b/dist/index.cjs -index a9afbc1ab78cad7ba97d53882a80236af62854df..141532779be923b8f3b421b0afc031b5ea209e29 100644 +index a9afbc1ab78cad7ba97d53882a80236af62854df..39cfc8dd597cbf3c3cd4ac1665bed4246fcb15cf 100644 --- a/dist/index.cjs +++ b/dist/index.cjs -@@ -1277,6 +1277,35 @@ var RouteMapSymbol = Symbol("RouteMap"); +@@ -406,6 +406,20 @@ var ArgumentParseError = class extends ArgumentScannerError { + this.exception = exception; + } + }; ++async function combinePositionalValues(positional, values_p, inputs, context) { ++ const values = await values_p; ++ if (values.status === "rejected") { ++ return values; ++ } ++ try { ++ return { status: "fulfilled", value: [await positional.combine.call(context, values.value)] }; ++ } catch (exc) { ++ return { ++ status: "rejected", ++ reasons: [new ArgumentParseError(getPlaceholder(positional.parameter), inputs.join(" "), exc)] ++ }; ++ } ++} + function parseInput(externalFlagNameOrPlaceholder, parameter, input, context) { + try { + return parameter.parse.call(context, input); +@@ -880,6 +894,19 @@ function buildArgumentScanner(parameters, config) { + return parseInput(placeholder, positional.parameter, input, context); + }) + ); ++ // PATCH(getsentry/cli): optional `combine` for array positionals. ++ // Some commands read their arguments as a whole: what each one means ++ // depends on how many were given (e.g. `[] `), which a ++ // per-element `parse` cannot see. When the array positional declares ++ // `combine`, it receives the parsed elements (and the command context ++ // as `this`) and returns the single value the command receives as its ++ // only positional argument. A throw becomes an ArgumentParseError for ++ // the joined inputs, like any other parse failure. The option is inert ++ // when unset, so stock Stricli behavior is unchanged. If ++ // `combinePositionalValues` is missing, the CLI's `check:patches` fails. ++ if (typeof positional.combine === "function") { ++ positionalValues_p = combinePositionalValues(positional, positionalValues_p, positionalInputs, context); ++ } + } else { + positionalValues_p = allSettledOrElse( + positional.parameters.map(async (param, i) => { +@@ -1277,6 +1304,35 @@ var RouteMapSymbol = Symbol("RouteMap"); var CommandSymbol = Symbol("Command"); // src/routing/scanner.ts @@ -38,7 +79,7 @@ index a9afbc1ab78cad7ba97d53882a80236af62854df..141532779be923b8f3b421b0afc031b5 function buildRouteScanner(root, config, startingPrefix) { const prefix = [...startingPrefix]; const unprocessedInputs = []; -@@ -1285,7 +1314,14 @@ function buildRouteScanner(root, config, startingPrefix) { +@@ -1285,7 +1341,14 @@ function buildRouteScanner(root, config, startingPrefix) { let target; let rootLevel = true; let helpRequested = false; @@ -53,7 +94,7 @@ index a9afbc1ab78cad7ba97d53882a80236af62854df..141532779be923b8f3b421b0afc031b5 return { next: (input) => { if (!treatInputsAsArguments && config.allowArgumentEscapeSequence && input === "--") { -@@ -1293,6 +1329,14 @@ function buildRouteScanner(root, config, startingPrefix) { +@@ -1293,6 +1356,14 @@ function buildRouteScanner(root, config, startingPrefix) { unprocessedInputs.push(input); return; } @@ -68,7 +109,7 @@ index a9afbc1ab78cad7ba97d53882a80236af62854df..141532779be923b8f3b421b0afc031b5 if (!treatInputsAsArguments) { if (input === "--help" || input === "-h") { helpRequested = true; -@@ -1300,12 +1341,28 @@ function buildRouteScanner(root, config, startingPrefix) { +@@ -1300,12 +1371,28 @@ function buildRouteScanner(root, config, startingPrefix) { target = current; } return; @@ -98,7 +139,7 @@ index a9afbc1ab78cad7ba97d53882a80236af62854df..141532779be923b8f3b421b0afc031b5 } } if (target) { -@@ -1357,6 +1414,7 @@ function buildRouteScanner(root, config, startingPrefix) { +@@ -1357,6 +1444,7 @@ function buildRouteScanner(root, config, startingPrefix) { target, unprocessedInputs, helpRequested, @@ -106,7 +147,7 @@ index a9afbc1ab78cad7ba97d53882a80236af62854df..141532779be923b8f3b421b0afc031b5 prefix, rootLevel, aliases -@@ -1410,16 +1468,6 @@ async function runApplication({ root, defaultText, config }, rawInputs, context) +@@ -1410,16 +1498,6 @@ async function runApplication({ root, defaultText, config }, rawInputs, context) } } const inputs = rawInputs.slice(); @@ -123,7 +164,7 @@ index a9afbc1ab78cad7ba97d53882a80236af62854df..141532779be923b8f3b421b0afc031b5 const scanner = buildRouteScanner(root, config.scanner, [config.name]); let error; while (inputs.length > 0 && !error) { -@@ -1445,7 +1493,37 @@ async function runApplication({ root, defaultText, config }, rawInputs, context) +@@ -1445,7 +1523,37 @@ async function runApplication({ root, defaultText, config }, rawInputs, context) return ExitCode.UnknownCommand; } const result = scanner.finish(); @@ -161,7 +202,7 @@ index a9afbc1ab78cad7ba97d53882a80236af62854df..141532779be923b8f3b421b0afc031b5 const ansiColor = shouldUseAnsiColor(context.process, context.process.stdout, config.documentation); context.process.stdout.write( result.target.formatHelp({ -@@ -1514,6 +1592,9 @@ function withDefaults(config) { +@@ -1514,6 +1622,9 @@ function withDefaults(config) { const scannerConfig = { caseStyle: scannerCaseStyle, allowArgumentEscapeSequence: config.scanner?.allowArgumentEscapeSequence ?? false, @@ -171,7 +212,7 @@ index a9afbc1ab78cad7ba97d53882a80236af62854df..141532779be923b8f3b421b0afc031b5 distanceOptions: config.scanner?.distanceOptions ?? { threshold: 7, weights: { -@@ -1529,7 +1610,10 @@ function withDefaults(config) { +@@ -1529,7 +1640,10 @@ function withDefaults(config) { useAliasInUsageLine: config.documentation?.useAliasInUsageLine ?? false, onlyRequiredInUsageLine: config.documentation?.onlyRequiredInUsageLine ?? false, caseStyle: displayCaseStyle, @@ -183,7 +224,7 @@ index a9afbc1ab78cad7ba97d53882a80236af62854df..141532779be923b8f3b421b0afc031b5 }; const completionConfig = { includeAliases: config.completion?.includeAliases ?? documentationConfig.useAliasInUsageLine, -@@ -1881,7 +1965,7 @@ function formatDocumentationForFlagParameters(flags, aliases, args) { +@@ -1881,7 +1995,7 @@ function formatDocumentationForFlagParameters(flags, aliases, args) { if (args.includeHelpAllFlag) { const helpAllFlagName = formatForDisplay("helpAll", args.config.caseStyle); rows.push({ @@ -192,7 +233,7 @@ index a9afbc1ab78cad7ba97d53882a80236af62854df..141532779be923b8f3b421b0afc031b5 flagName: atLeastOneOptional ? ` --${helpAllFlagName}` : `--${helpAllFlagName}`, brief: briefs.helpAll, hidden: !args.config.alwaysShowHelpAllFlag -@@ -1920,7 +2004,7 @@ function* generateBuiltInFlagUsageLines(args) { +@@ -1920,7 +2034,7 @@ function* generateBuiltInFlagUsageLines(args) { yield args.config.useAliasInUsageLine ? "-h" : "--help"; if (args.includeHelpAllFlag) { const helpAllFlagName = formatForDisplay("helpAll", args.config.caseStyle); @@ -201,7 +242,7 @@ index a9afbc1ab78cad7ba97d53882a80236af62854df..141532779be923b8f3b421b0afc031b5 } if (args.includeVersionFlag) { yield args.config.useAliasInUsageLine ? "-v" : "--version"; -@@ -2057,7 +2141,7 @@ function checkForInvalidVariadicSeparators(flags) { +@@ -2057,7 +2171,7 @@ function checkForInvalidVariadicSeparators(flags) { function buildCommand(builderArgs) { const { flags = {}, aliases = {} } = builderArgs.parameters; checkForReservedFlags(flags, ["help", "helpAll", "help-all"]); @@ -211,7 +252,7 @@ index a9afbc1ab78cad7ba97d53882a80236af62854df..141532779be923b8f3b421b0afc031b5 checkForInvalidVariadicSeparators(flags); let loader; diff --git a/dist/index.d.cts b/dist/index.d.cts -index 111d1fd9e926f23b71359a692cd2e9e05ebf379e..1714047c8cf7d46e38062bab670451851709a7e2 100644 +index 111d1fd9e926f23b71359a692cd2e9e05ebf379e..ff53a12a8477e79580d62145134c25f6e9da923e 100644 --- a/dist/index.d.cts +++ b/dist/index.d.cts @@ -402,6 +402,25 @@ interface ScannerConfiguration { @@ -269,8 +310,22 @@ index 111d1fd9e926f23b71359a692cd2e9e05ebf379e..1714047c8cf7d46e38062bab67045185 } /** * Configuration for controlling the behavior of completion proposals. +@@ -993,6 +1034,13 @@ interface PositionalParameterArray { + readonly parameter: TypedPositionalParameter; + readonly minimum?: number; + readonly maximum?: number; ++ /** ++ * PATCH(getsentry/cli): reads the parsed arguments as a whole and returns ++ * the single value the command receives as its only positional argument. ++ * Use it when what each argument means depends on how many were given. ++ * A throw fails like a parse error for the joined inputs. ++ */ ++ readonly combine?: (this: CONTEXT, values: readonly T[]) => unknown; + } + type PositionalParametersForTuple = { + readonly [K in keyof T]: TypedPositionalParameter; diff --git a/dist/index.d.ts b/dist/index.d.ts -index 111d1fd9e926f23b71359a692cd2e9e05ebf379e..1714047c8cf7d46e38062bab670451851709a7e2 100644 +index 111d1fd9e926f23b71359a692cd2e9e05ebf379e..ff53a12a8477e79580d62145134c25f6e9da923e 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -402,6 +402,25 @@ interface ScannerConfiguration { @@ -328,11 +383,66 @@ index 111d1fd9e926f23b71359a692cd2e9e05ebf379e..1714047c8cf7d46e38062bab67045185 } /** * Configuration for controlling the behavior of completion proposals. +@@ -993,6 +1034,13 @@ interface PositionalParameterArray { + readonly parameter: TypedPositionalParameter; + readonly minimum?: number; + readonly maximum?: number; ++ /** ++ * PATCH(getsentry/cli): reads the parsed arguments as a whole and returns ++ * the single value the command receives as its only positional argument. ++ * Use it when what each argument means depends on how many were given. ++ * A throw fails like a parse error for the joined inputs. ++ */ ++ readonly combine?: (this: CONTEXT, values: readonly T[]) => unknown; + } + type PositionalParametersForTuple = { + readonly [K in keyof T]: TypedPositionalParameter; diff --git a/dist/index.js b/dist/index.js -index f76639637e812453d107bda3e3ec165fc195f4d8..eff534dc79cdbe5bf8ee14839157b6d87c52de17 100644 +index f76639637e812453d107bda3e3ec165fc195f4d8..b4b98087a21dd4566b100758fda1f2be861ce3c4 100644 --- a/dist/index.js +++ b/dist/index.js -@@ -1229,6 +1229,35 @@ var RouteMapSymbol = Symbol("RouteMap"); +@@ -358,6 +358,20 @@ var ArgumentParseError = class extends ArgumentScannerError { + this.exception = exception; + } + }; ++async function combinePositionalValues(positional, values_p, inputs, context) { ++ const values = await values_p; ++ if (values.status === "rejected") { ++ return values; ++ } ++ try { ++ return { status: "fulfilled", value: [await positional.combine.call(context, values.value)] }; ++ } catch (exc) { ++ return { ++ status: "rejected", ++ reasons: [new ArgumentParseError(getPlaceholder(positional.parameter), inputs.join(" "), exc)] ++ }; ++ } ++} + function parseInput(externalFlagNameOrPlaceholder, parameter, input, context) { + try { + return parameter.parse.call(context, input); +@@ -832,6 +846,19 @@ function buildArgumentScanner(parameters, config) { + return parseInput(placeholder, positional.parameter, input, context); + }) + ); ++ // PATCH(getsentry/cli): optional `combine` for array positionals. ++ // Some commands read their arguments as a whole: what each one means ++ // depends on how many were given (e.g. `[] `), which a ++ // per-element `parse` cannot see. When the array positional declares ++ // `combine`, it receives the parsed elements (and the command context ++ // as `this`) and returns the single value the command receives as its ++ // only positional argument. A throw becomes an ArgumentParseError for ++ // the joined inputs, like any other parse failure. The option is inert ++ // when unset, so stock Stricli behavior is unchanged. If ++ // `combinePositionalValues` is missing, the CLI's `check:patches` fails. ++ if (typeof positional.combine === "function") { ++ positionalValues_p = combinePositionalValues(positional, positionalValues_p, positionalInputs, context); ++ } + } else { + positionalValues_p = allSettledOrElse( + positional.parameters.map(async (param, i) => { +@@ -1229,6 +1256,35 @@ var RouteMapSymbol = Symbol("RouteMap"); var CommandSymbol = Symbol("Command"); // src/routing/scanner.ts @@ -368,7 +478,7 @@ index f76639637e812453d107bda3e3ec165fc195f4d8..eff534dc79cdbe5bf8ee14839157b6d8 function buildRouteScanner(root, config, startingPrefix) { const prefix = [...startingPrefix]; const unprocessedInputs = []; -@@ -1237,7 +1266,14 @@ function buildRouteScanner(root, config, startingPrefix) { +@@ -1237,7 +1293,14 @@ function buildRouteScanner(root, config, startingPrefix) { let target; let rootLevel = true; let helpRequested = false; @@ -383,7 +493,7 @@ index f76639637e812453d107bda3e3ec165fc195f4d8..eff534dc79cdbe5bf8ee14839157b6d8 return { next: (input) => { if (!treatInputsAsArguments && config.allowArgumentEscapeSequence && input === "--") { -@@ -1245,6 +1281,14 @@ function buildRouteScanner(root, config, startingPrefix) { +@@ -1245,6 +1308,14 @@ function buildRouteScanner(root, config, startingPrefix) { unprocessedInputs.push(input); return; } @@ -398,7 +508,7 @@ index f76639637e812453d107bda3e3ec165fc195f4d8..eff534dc79cdbe5bf8ee14839157b6d8 if (!treatInputsAsArguments) { if (input === "--help" || input === "-h") { helpRequested = true; -@@ -1252,12 +1293,28 @@ function buildRouteScanner(root, config, startingPrefix) { +@@ -1252,12 +1323,28 @@ function buildRouteScanner(root, config, startingPrefix) { target = current; } return; @@ -428,7 +538,7 @@ index f76639637e812453d107bda3e3ec165fc195f4d8..eff534dc79cdbe5bf8ee14839157b6d8 } } if (target) { -@@ -1309,6 +1366,7 @@ function buildRouteScanner(root, config, startingPrefix) { +@@ -1309,6 +1396,7 @@ function buildRouteScanner(root, config, startingPrefix) { target, unprocessedInputs, helpRequested, @@ -436,7 +546,7 @@ index f76639637e812453d107bda3e3ec165fc195f4d8..eff534dc79cdbe5bf8ee14839157b6d8 prefix, rootLevel, aliases -@@ -1362,16 +1420,6 @@ async function runApplication({ root, defaultText, config }, rawInputs, context) +@@ -1362,16 +1450,6 @@ async function runApplication({ root, defaultText, config }, rawInputs, context) } } const inputs = rawInputs.slice(); @@ -453,7 +563,7 @@ index f76639637e812453d107bda3e3ec165fc195f4d8..eff534dc79cdbe5bf8ee14839157b6d8 const scanner = buildRouteScanner(root, config.scanner, [config.name]); let error; while (inputs.length > 0 && !error) { -@@ -1397,7 +1445,37 @@ async function runApplication({ root, defaultText, config }, rawInputs, context) +@@ -1397,7 +1475,37 @@ async function runApplication({ root, defaultText, config }, rawInputs, context) return ExitCode.UnknownCommand; } const result = scanner.finish(); @@ -491,7 +601,7 @@ index f76639637e812453d107bda3e3ec165fc195f4d8..eff534dc79cdbe5bf8ee14839157b6d8 const ansiColor = shouldUseAnsiColor(context.process, context.process.stdout, config.documentation); context.process.stdout.write( result.target.formatHelp({ -@@ -1466,6 +1544,9 @@ function withDefaults(config) { +@@ -1466,6 +1574,9 @@ function withDefaults(config) { const scannerConfig = { caseStyle: scannerCaseStyle, allowArgumentEscapeSequence: config.scanner?.allowArgumentEscapeSequence ?? false, @@ -501,7 +611,7 @@ index f76639637e812453d107bda3e3ec165fc195f4d8..eff534dc79cdbe5bf8ee14839157b6d8 distanceOptions: config.scanner?.distanceOptions ?? { threshold: 7, weights: { -@@ -1481,7 +1562,10 @@ function withDefaults(config) { +@@ -1481,7 +1592,10 @@ function withDefaults(config) { useAliasInUsageLine: config.documentation?.useAliasInUsageLine ?? false, onlyRequiredInUsageLine: config.documentation?.onlyRequiredInUsageLine ?? false, caseStyle: displayCaseStyle, @@ -513,7 +623,7 @@ index f76639637e812453d107bda3e3ec165fc195f4d8..eff534dc79cdbe5bf8ee14839157b6d8 }; const completionConfig = { includeAliases: config.completion?.includeAliases ?? documentationConfig.useAliasInUsageLine, -@@ -1833,7 +1917,7 @@ function formatDocumentationForFlagParameters(flags, aliases, args) { +@@ -1833,7 +1947,7 @@ function formatDocumentationForFlagParameters(flags, aliases, args) { if (args.includeHelpAllFlag) { const helpAllFlagName = formatForDisplay("helpAll", args.config.caseStyle); rows.push({ @@ -522,7 +632,7 @@ index f76639637e812453d107bda3e3ec165fc195f4d8..eff534dc79cdbe5bf8ee14839157b6d8 flagName: atLeastOneOptional ? ` --${helpAllFlagName}` : `--${helpAllFlagName}`, brief: briefs.helpAll, hidden: !args.config.alwaysShowHelpAllFlag -@@ -1872,7 +1956,7 @@ function* generateBuiltInFlagUsageLines(args) { +@@ -1872,7 +1986,7 @@ function* generateBuiltInFlagUsageLines(args) { yield args.config.useAliasInUsageLine ? "-h" : "--help"; if (args.includeHelpAllFlag) { const helpAllFlagName = formatForDisplay("helpAll", args.config.caseStyle); @@ -531,7 +641,7 @@ index f76639637e812453d107bda3e3ec165fc195f4d8..eff534dc79cdbe5bf8ee14839157b6d8 } if (args.includeVersionFlag) { yield args.config.useAliasInUsageLine ? "-v" : "--version"; -@@ -2009,7 +2093,7 @@ function checkForInvalidVariadicSeparators(flags) { +@@ -2009,7 +2123,7 @@ function checkForInvalidVariadicSeparators(flags) { function buildCommand(builderArgs) { const { flags = {}, aliases = {} } = builderArgs.parameters; checkForReservedFlags(flags, ["help", "helpAll", "help-all"]); diff --git a/packages/cli/script/check-patches.ts b/packages/cli/script/check-patches.ts index c263c0ba0..fe34f534c 100644 --- a/packages/cli/script/check-patches.ts +++ b/packages/cli/script/check-patches.ts @@ -221,6 +221,13 @@ for (const [key, patchPath] of Object.entries(patches)) { * it's guarded by a `requiredMarker` (`matchTopLevelFlag`) that must be present * once patched. Its absence means global flags before a subcommand will crash. * + * @stricli/core (array positional `combine`): an array positional may declare + * `combine`, which reads the parsed arguments as a whole and returns the one + * value the command gets, so a parse that depends on how many arguments were + * given runs in the argument scanner (see `boundArguments` in + * `src/lib/positional-parse.ts`). A pure insertion, guarded by the + * `combinePositionalValues` marker. + * * @stricli/core (`-v` version alias): the patch also drops Stricli's built-in * `-v`=version alias in `runApplication` so `-v` stays the Sentry CLI's * `--verbose` alias at every position; `--version` remains the version flag. @@ -299,6 +306,18 @@ const CONTENT_ASSERTIONS: ReadonlyArray<{ description: "@stricli/core CJS: pluggable `renderHelp` hook missing (`sentry --help --json` will fall back to text help instead of structured JSON)", }, + { + file: "@stricli/core/dist/index.js", + requiredMarker: "combinePositionalValues", + description: + "@stricli/core ESM: array positional `combine` hook missing (commands such as `log list` and `replay view` get no parsed arguments)", + }, + { + file: "@stricli/core/dist/index.cjs", + requiredMarker: "combinePositionalValues", + description: + "@stricli/core CJS: array positional `combine` hook missing (commands such as `log list` and `replay view` get no parsed arguments)", + }, ]; for (const assertion of CONTENT_ASSERTIONS) { diff --git a/packages/cli/src/commands/log/list.ts b/packages/cli/src/commands/log/list.ts index 0c5f6a5d6..5a23fe479 100644 --- a/packages/cli/src/commands/log/list.ts +++ b/packages/cli/src/commands/log/list.ts @@ -16,10 +16,12 @@ import { } from "../../lib/api-client.js"; import { buildProjectQuery, + type ParsedOrgProject, parseLogSort, + parseOrgProjectInput, validateLimit, } from "../../lib/arg-parsing.js"; -import { parseOrgProjectTarget } from "../../lib/completion-target.js"; +import { ORG_PROJECT_TARGET } from "../../lib/completion-target.js"; import { AuthError, stringifyUnknown, @@ -50,9 +52,13 @@ import { } from "../../lib/list-command.js"; import { logger } from "../../lib/logger.js"; import { withProgress } from "../../lib/polling.js"; +import { + type BoundArguments, + boundArguments, +} from "../../lib/positional-parse.js"; import { resolveLogProjectId, - resolveProjectBoundFromArg, + resolveProjectBoundTarget, } from "../../lib/resolve-target.js"; import { sanitizeQuery } from "../../lib/search-query.js"; import { @@ -63,9 +69,12 @@ import { type TimeRange, timeRangeToApiParams, } from "../../lib/time-range.js"; +import { applySentryUrlContext } from "../../lib/sentry-url-parser.js"; import { - parseDualModeArgs, + applyTraceTargetInput, + parseDualModeInput, resolveTraceOrg, + type TraceTargetInput, warnIfNormalized, } from "../../lib/trace-target.js"; import { getUpdateNotification } from "../../lib/version-check.js"; @@ -155,16 +164,71 @@ type FetchResult = { // Positional argument disambiguation // --------------------------------------------------------------------------- +/** What `log list` lists, as parsed: a project's logs or a trace's. */ +type LogListInput = + | { + readonly mode: "project"; + readonly target: ParsedOrgProject; + /** Server of a pasted Sentry URL, for `applySentryUrlContext` */ + readonly baseUrl?: string; + } + | { readonly mode: "trace"; readonly trace: TraceTargetInput }; + +/** What `log list` lists, resolved. */ +type LogListTarget = + | { readonly mode: "project"; readonly org: string; readonly project: string } + | { + readonly mode: "trace"; + readonly org: string; + readonly traceId: string; + /** Project the user named, to filter the trace's logs by */ + readonly projectFilter?: string; + }; + +/** The arguments of `log list`. */ +type LogListArguments = BoundArguments; + /** - * Disambiguate log list positional arguments. - * - * Thin wrapper around {@link parseDualModeArgs} that binds the - * trace-mode usage hint for log list. + * Disambiguate log list positional arguments with {@link parseDualModeInput} + * (trace mode when the arguments end in a trace ID). */ -function parseLogListArgs( - args: string[], -): ReturnType { - return parseDualModeArgs(args, TRACE_USAGE_HINT); +function parseLogListInput(args: readonly string[]): LogListInput { + const input = parseDualModeInput(args, TRACE_USAGE_HINT); + if (input.mode === "trace") { + return input; + } + const { parsed, baseUrl } = parseOrgProjectInput(input.target); + return { mode: "project", target: parsed, baseUrl }; +} + +async function resolveLogListTarget( + input: LogListInput, + context: SentryContext, +): Promise { + if (input.mode === "trace") { + const parsed = applyTraceTargetInput(input.trace); + warnIfNormalized(parsed, "log.list"); + const { traceId, org } = await resolveTraceOrg( + parsed, + context.cwd, + TRACE_USAGE_HINT, + ); + return { + mode: "trace", + org, + traceId, + projectFilter: parsed.type === "explicit" ? parsed.project : undefined, + }; + } + if (input.baseUrl) { + applySentryUrlContext(input.baseUrl); + } + const { org, project } = await resolveProjectBoundTarget( + input.target, + context.cwd, + COMMAND_NAME, + ); + return { mode: "project", org, project }; } /** @@ -758,15 +822,13 @@ export const listCommand = buildListCommand( schema: SentryLogSchema, }, parameters: { - positional: { - kind: "array", - parameter: { - placeholder: "org/project-or-trace-id", - brief: - "[/[/]], /, or ", - parse: parseOrgProjectTarget, - }, - }, + positional: boundArguments({ + placeholder: "org/project-or-trace-id", + brief: "[/[/]], /, or ", + kind: ORG_PROJECT_TARGET, + parse: parseLogListInput, + resolve: resolveLogListTarget, + }), flags: { limit: { kind: "parsed", @@ -809,17 +871,17 @@ export const listCommand = buildListCommand( s: "sort", }, }, - async *func(this: SentryContext, flags: ListFlags, ...args: string[]) { + async *func( + this: SentryContext, + flags: ListFlags, + target: LogListArguments, + ) { validateFollowFlags(flags); - const { cwd } = this; - - const parsed = parseLogListArgs(args); - // Resolve mode-dependent default period const timeRange = flags.period ?? - (parsed.mode === "trace" ? TIME_RANGE_14D : TIME_RANGE_30D); + (target.parsed.mode === "trace" ? TIME_RANGE_14D : TIME_RANGE_30D); // Follow mode streams live events via short polling intervals — // absolute date ranges are silently ignored, so reject them entirely. @@ -831,14 +893,10 @@ export const listCommand = buildListCommand( ); } - if (parsed.mode === "trace") { + const resolved = await target.resolved; + if (resolved.mode === "trace") { // Trace mode: use the org-scoped trace-logs endpoint. - warnIfNormalized(parsed.parsed, "log.list"); - const { traceId, org } = await resolveTraceOrg( - parsed.parsed, - cwd, - TRACE_USAGE_HINT, - ); + const { traceId, org, projectFilter } = resolved; // Warn if --fields was passed — the trace-logs endpoint has a fixed // field set and doesn't support arbitrary extra fields. @@ -848,10 +906,6 @@ export const listCommand = buildListCommand( ); } - // Capture explicit project for API-level filtering - const projectFilter = - parsed.parsed.type === "explicit" ? parsed.parsed.project : undefined; - // Prepend project filter to the query when user explicitly specified a project const traceQuery = buildProjectQuery(flags.query, projectFilter); @@ -927,11 +981,7 @@ export const listCommand = buildListCommand( // Standard project-scoped mode { - const { org, project } = await resolveProjectBoundFromArg( - parsed.target, - cwd, - COMMAND_NAME, - ); + const { org, project } = resolved; // Resolve the slug to a numeric project ID so the Events query scopes // via the `project` param. The `project:` filter only matches // actively-selected projects and can otherwise return no logs (#1317). diff --git a/packages/cli/src/commands/replay/download.ts b/packages/cli/src/commands/replay/download.ts index 996e30840..6637115b2 100644 --- a/packages/cli/src/commands/replay/download.ts +++ b/packages/cli/src/commands/replay/download.ts @@ -19,7 +19,6 @@ import { formatReplayDownloadResult, type ReplayDownloadData, } from "../../lib/formatters/replay.js"; -import { validateHexId } from "../../lib/hex-id.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -31,9 +30,12 @@ import { rrwebDurationMs, toRRWebEvents, } from "../../lib/replay-rrweb.js"; -import { resolveOrgOptionalFromArg } from "../../lib/resolve-target.js"; import type { ReplayDetails } from "../../types/index.js"; -import { parsePositionalArgs, validateReplayProjectScope } from "./view.js"; +import { + type ReplayArguments, + replayArguments, + validateReplayProjectScope, +} from "./view.js"; type DownloadFlags = { readonly output?: string; @@ -81,14 +83,7 @@ export const downloadCommand = buildCommand({ human: formatReplayDownloadResult, }, parameters: { - positional: { - kind: "array", - parameter: { - placeholder: "replay-id-or-url", - brief: "[/] or ", - parse: String, - }, - }, + positional: replayArguments("download", USAGE_HINT), flags: { output: { kind: "parsed", @@ -101,21 +96,16 @@ export const downloadCommand = buildCommand({ }, aliases: { ...FRESH_ALIASES, o: "output" }, }, - async *func(this: SentryContext, flags: DownloadFlags, ...args: string[]) { + async *func( + this: SentryContext, + flags: DownloadFlags, + target: ReplayArguments, + ) { applyFreshFlag(flags); const { cwd } = this; - const parsedArgs = parsePositionalArgs(args, USAGE_HINT); - if (parsedArgs.warning) { - log.warn(parsedArgs.warning); - } - - const replayId = validateHexId(parsedArgs.replayId, "replay ID"); - const resolved = await resolveOrgOptionalFromArg( - parsedArgs.targetArg, - cwd, - "replay download", - ); + const resolved = await target.resolved; + const { replayId } = resolved; let replay: ReplayDetails; try { diff --git a/packages/cli/src/commands/replay/view.ts b/packages/cli/src/commands/replay/view.ts index ee2e5132e..044d75cbe 100644 --- a/packages/cli/src/commands/replay/view.ts +++ b/packages/cli/src/commands/replay/view.ts @@ -14,11 +14,13 @@ import { } from "../../lib/api-client.js"; import { detectSwappedViewArgs, + type ParsedOrgProject, + parseOrgProjectInput, parseSlashSeparatedArg, } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; -import { parseOrgProjectTarget } from "../../lib/completion-target.js"; +import { ORG_PROJECT_TARGET } from "../../lib/completion-target.js"; import { ApiError, ContextError, @@ -40,7 +42,14 @@ import { FRESH_FLAG, } from "../../lib/list-command.js"; import { logger } from "../../lib/logger.js"; -import { resolveOrgOptionalFromArg } from "../../lib/resolve-target.js"; +import { + type BoundArguments, + boundArguments, +} from "../../lib/positional-parse.js"; +import { + type ResolvedOrgOptionalTarget, + resolveOrgOptionalTarget, +} from "../../lib/resolve-target.js"; import { applySentryUrlContext, parseSentryUrl, @@ -65,6 +74,8 @@ type ParsedPositionalArgs = { replayId: string; targetArg: string | undefined; warning?: string; + /** Server of a pasted replay URL, for `applySentryUrlContext` */ + baseUrl?: string; }; const USAGE_HINT = @@ -124,9 +135,10 @@ function parseSingleArg(arg: string, usageHint: string): ParsedPositionalArgs { * - `` * * `usageHint` lets sibling replay commands show their own usage in errors. + * Has no side effects: a pasted URL's server is returned as `baseUrl`. */ export function parsePositionalArgs( - args: string[], + args: readonly string[], usageHint = USAGE_HINT, ): ParsedPositionalArgs { if (args.length === 0) { @@ -146,9 +158,12 @@ export function parsePositionalArgs( const urlParsed = parseSentryUrl(first); if (urlParsed) { - applySentryUrlContext(urlParsed.baseUrl); if (urlParsed.replayId && urlParsed.org) { - return { replayId: urlParsed.replayId, targetArg: `${urlParsed.org}/` }; + return { + replayId: urlParsed.replayId, + targetArg: `${urlParsed.org}/`, + baseUrl: urlParsed.baseUrl, + }; } throw new ContextError("Replay ID", usageHint, [ "Pass a replay URL: https://sentry.io/organizations/{org}/explore/replays/{replayId}/", @@ -178,6 +193,72 @@ export function parsePositionalArgs( return { replayId: second, targetArg: first }; } +/** Parsed `replay view` and `replay download` arguments. */ +export type ReplayArgumentsInput = { + readonly replayId: string; + readonly target: ParsedOrgProject; + /** Server of a pasted Sentry URL, for `applySentryUrlContext` */ + readonly baseUrl?: string; + readonly warning?: string; +}; + +/** A replay ID and the org (and optional project) to look it up in. */ +export type ResolvedReplayTarget = ResolvedOrgOptionalTarget & { + readonly replayId: string; +}; + +/** The arguments of `replay view` and `replay download`. */ +export type ReplayArguments = BoundArguments< + ReplayArgumentsInput, + ResolvedReplayTarget +>; + +/** + * Positional for `[/] ` or a replay URL, shared by + * `replay view` and `replay download`. + */ +export function replayArguments( + command: "view" | "download", + usageHint: string, +) { + const commandLog = logger.withTag(`replay.${command}`); + return boundArguments({ + placeholder: "replay-id-or-url", + brief: "[/] or ", + kind: ORG_PROJECT_TARGET, + parse: (raw): ReplayArgumentsInput => { + const { replayId, targetArg, warning, baseUrl } = parsePositionalArgs( + raw, + usageHint, + ); + const target = parseOrgProjectInput(targetArg); + return { + replayId: validateHexId(replayId, "replay ID"), + target: target.parsed, + baseUrl: baseUrl ?? target.baseUrl, + warning, + }; + }, + resolve: async ( + input, + context: SentryContext, + ): Promise => { + if (input.baseUrl) { + applySentryUrlContext(input.baseUrl); + } + if (input.warning) { + commandLog.warn(input.warning); + } + const resolved = await resolveOrgOptionalTarget( + input.target, + context.cwd, + `replay ${command}`, + ); + return { ...resolved, replayId: input.replayId }; + }, + }); +} + type ReplayProjectScope = { org: string; project?: string; @@ -397,14 +478,7 @@ export const viewCommand = buildCommand({ schema: ReplayViewOutputSchema, }, parameters: { - positional: { - kind: "array", - parameter: { - placeholder: "replay-id-or-url", - brief: "[/] or ", - parse: parseOrgProjectTarget, - }, - }, + positional: replayArguments("view", USAGE_HINT), flags: { web: { kind: "boolean", @@ -415,21 +489,11 @@ export const viewCommand = buildCommand({ }, aliases: { ...FRESH_ALIASES, w: "web" }, }, - async *func(this: SentryContext, flags: ViewFlags, ...args: string[]) { + async *func(this: SentryContext, flags: ViewFlags, target: ReplayArguments) { applyFreshFlag(flags); - const { cwd } = this; - const parsedArgs = parsePositionalArgs(args); - if (parsedArgs.warning) { - log.warn(parsedArgs.warning); - } - - const replayId = validateHexId(parsedArgs.replayId, "replay ID"); - const resolved = await resolveOrgOptionalFromArg( - parsedArgs.targetArg, - cwd, - "replay view", - ); + const resolved = await target.resolved; + const { replayId } = resolved; let replay: ReplayDetails; try { diff --git a/packages/cli/src/lib/command.ts b/packages/cli/src/lib/command.ts index bc073dd4b..4a8eee892 100644 --- a/packages/cli/src/lib/command.ts +++ b/packages/cli/src/lib/command.ts @@ -69,7 +69,9 @@ import { import { applyBoundPositionals, checkDestructive, + isHelpArgument, rawPositional, + throwDeferredParseErrors, } from "./positional-parse.js"; import { setArgsContext, setFlagContext, withTracing } from "./telemetry.js"; import { applyTipsPreference } from "./tips.js"; @@ -695,13 +697,7 @@ export function buildCommand< if (!(err instanceof CliError) || err instanceof OutputError) { return false; } - if ( - args.length === 0 || - !args.some((a) => { - const raw = rawPositional(a); - return raw === "help" || raw === "--h" || raw === "-help"; - }) - ) { + if (!args.flatMap(rawPositional).some(isHelpArgument)) { return false; } if (!ctx.commandPrefix || ctx.directInvoke) { @@ -757,7 +753,7 @@ export function buildCommand< const cleanFlags = cleanRawFlags(flags as Record); setFlagContext(cleanFlags); if (args.length > 0) { - setArgsContext(args.map(rawPositional)); + setArgsContext(args.flatMap(rawPositional)); } // Environment-based JSON mode (used by library entry point) @@ -833,6 +829,10 @@ export function buildCommand< await assertRcUrlTrusted(this.cwd as string); } + // An argument that failed to parse next to a `help` argument fails + // here, where it can turn into the command's help + throwDeferredParseErrors(args); + // Bound target positionals of destructive commands refuse to // auto-detect, whether `func` reads `parsed` or `resolved` if (builderArgs.destructive) { diff --git a/packages/cli/src/lib/list-command.ts b/packages/cli/src/lib/list-command.ts index 663f4beb7..415d66dd5 100644 --- a/packages/cli/src/lib/list-command.ts +++ b/packages/cli/src/lib/list-command.ts @@ -666,16 +666,21 @@ export function buildListCommand< // The first positional arg is always the target (org/project pattern), // either a raw string or a parsed value. Intercept it to handle plural // alias confusion. - const rawTarget = rawPositional(args[0]); + const raw = rawPositional(args[0]); + const rawTarget = Array.isArray(raw) ? raw[0] : raw; + let remaining = args; if (typeof rawTarget === "string") { // All list commands use SentryContext which has stderr at top level const ctx = this as unknown as { stderr: { write(s: string): void } }; if (interceptSubcommand(rawTarget, ctx.stderr, routeName) === undefined) { - args[0] = undefined; + // Bound arguments are parsed again without it + remaining = Array.isArray(raw) + ? raw.slice(1) + : [undefined, ...args.slice(1)]; } } // A dropped bound target becomes an omitted one (auto-detect) - const finalArgs = applyBoundPositionals(args, params.positional, this); + const finalArgs = applyBoundPositionals(remaining, params.positional, this); return originalFunc.call(this, flags, ...(finalArgs as unknown as ARGS)); } as typeof originalFunc; diff --git a/packages/cli/src/lib/positional-parse.ts b/packages/cli/src/lib/positional-parse.ts index d4908d7da..cae3b9275 100644 --- a/packages/cli/src/lib/positional-parse.ts +++ b/packages/cli/src/lib/positional-parse.ts @@ -24,6 +24,11 @@ * always gets a {@link BoundPositional}. Shared code that handles any * positional (plural alias interception, `help` recovery, telemetry) reads * the raw input with {@link rawPositional}. + * + * {@link boundArguments} does the same for positionals whose meaning depends + * on how many arguments were given, such as `[/] `: it + * parses all of them at once (in the scanner, through our Stricli patch's + * `combine` hook) and hands `func` one {@link BoundArguments}. */ import { @@ -37,24 +42,37 @@ const destructiveChecks = new WeakMap void>(); /** Parsers made by {@link boundPositional}. */ const boundParsers = new WeakSet(); +/** `combine` functions made by {@link boundArguments}. */ +const argumentCombiners = new WeakSet(); + +/** Parse errors of {@link boundArguments} values, thrown when `func` runs. */ +const deferredParseErrors = new WeakMap(); + +/** + * Whether the user typed `help` (or a mistyped help flag) as an argument, + * which `buildCommand` answers with the command's help. + */ +export function isHelpArgument(raw: unknown): boolean { + return raw === "help" || raw === "--h" || raw === "-help"; +} + /** * A parsed positional value bound to its resolver, which runs once, on the * first `resolved` read. */ -export class BoundPositional { +export class BoundPositional { #resolved: Promise | undefined; readonly #resolve: (value: T) => Promise; - /** What the user typed; `undefined` for an omitted positional. */ - readonly raw: string | undefined; + /** + * What the user typed; `undefined` for an omitted positional, every + * argument for {@link boundArguments}. + */ + readonly raw: RAW; /** The parsed value. */ readonly parsed: T; - constructor( - raw: string | undefined, - parsed: T, - resolve: (value: T) => Promise, - ) { + constructor(raw: RAW, parsed: T, resolve: (value: T) => Promise) { this.raw = raw; this.parsed = parsed; this.#resolve = resolve; @@ -97,10 +115,87 @@ export function boundPositional( return parser; } +/** Every argument of a {@link boundArguments} positional, parsed and bound. */ +export type BoundArguments = BoundPositional; + +/** + * Build an array positional whose arguments parse as a whole, for syntaxes + * where what an argument means depends on how many were given (e.g. + * `[/] `, or a trailing trace ID). `func` gets one + * {@link BoundArguments} instead of `...args: string[]`. + * + * Each argument completes as `kind`. Stricli then hands every argument to + * `parse` through the positional's `combine` hook (our Stricli patch), so a + * parse error fails in the argument scanner like any other (exit 252). When + * an argument is `help`, the error is thrown when `func` would run instead, + * so `buildCommand` shows the command's help. + * + * @param options.parse - Parses all the arguments, possibly none; may + * throw. Must have no side effects + * @param options.resolve - Resolves the parsed value; gets the command + * context and the raw arguments + */ +export function boundArguments(options: { + readonly placeholder: string; + readonly brief: string; + readonly kind: CompletionTarget; + readonly parse: (raw: readonly string[]) => T; + readonly resolve: ( + value: T, + context: C, + raw: readonly string[], + ) => Promise; +}): { + readonly kind: "array"; + readonly parameter: { + readonly placeholder: string; + readonly brief: string; + readonly parse: (input: string) => string; + }; + readonly combine: ( + this: C, + values: readonly string[], + ) => BoundArguments; +} { + const combine = function (this: C, values: readonly string[]) { + const raw = [...values]; + let parsed: T; + try { + parsed = options.parse(raw); + } catch (error) { + if (!raw.some(isHelpArgument)) { + throw error; + } + const failed = new BoundPositional(raw, undefined as T, () => + Promise.reject(error), + ); + deferredParseErrors.set(failed, error); + return failed; + } + return new BoundPositional(raw, parsed, (value: T) => + options.resolve(value, this, raw), + ); + }; + argumentCombiners.add(combine); + return { + kind: "array", + parameter: { + placeholder: options.placeholder, + brief: options.brief, + parse: withCompletionTarget(options.kind, (input: string) => input), + }, + combine, + }; +} + /** A Stricli positional definition — the subset that carries the parsers. */ type PositionalDef = | { kind: "tuple"; parameters: readonly { parse?: unknown }[] } - | { kind: "array"; parameter: { parse?: unknown } }; + | { + kind: "array"; + parameter: { parse?: unknown }; + combine?: (this: unknown, values: readonly string[]) => unknown; + }; /** The {@link boundPositional} parser at a positional index, if any. */ function boundParserAt( @@ -120,7 +215,8 @@ function boundParserAt( * Stricli already ran the parsers for typed input. This covers callers that * skip its scanner (the SDK invoke path, tests calling the loaded func), * which pass raw strings, and omitted optional positionals, which Stricli - * leaves `undefined`. Other positionals are left as they are. + * leaves `undefined`. Other positionals are left as they are. Raw strings + * for a {@link boundArguments} positional become one {@link BoundArguments}. * * @param context - Passed to each parser as `this`, as Stricli does * @throws The parser's error for an invalid raw string @@ -134,6 +230,17 @@ export function applyBoundPositionals( if (!def) { return [...args]; } + if ( + def.kind === "array" && + def.combine && + argumentCombiners.has(def.combine) + ) { + // Stricli's scanner already combined them into one bound value + if (args.length === 1 && args[0] instanceof BoundPositional) { + return [...args]; + } + return [def.combine.call(context, args as readonly string[])]; + } const count = def.kind === "tuple" ? Math.max(args.length, def.parameters.length) @@ -162,10 +269,23 @@ export function checkDestructive(args: readonly unknown[]): void { } } +/** + * Throw the parse error {@link boundArguments} deferred because an argument + * was `help`. Other values are ignored. + */ +export function throwDeferredParseErrors(args: readonly unknown[]): void { + for (const arg of args) { + if (arg instanceof BoundPositional && deferredParseErrors.has(arg)) { + throw deferredParseErrors.get(arg); + } + } +} + /** * What the user typed for a positional value. * - * @returns `value.raw` for a {@link BoundPositional}, otherwise `value` + * @returns `value.raw` for a {@link BoundPositional} (every argument for + * {@link BoundArguments}), otherwise `value` */ export function rawPositional(value: unknown): unknown { return value instanceof BoundPositional ? value.raw : value; diff --git a/packages/cli/src/lib/trace-target.ts b/packages/cli/src/lib/trace-target.ts index 1801a93e3..7cfc4b251 100644 --- a/packages/cli/src/lib/trace-target.ts +++ b/packages/cli/src/lib/trace-target.ts @@ -16,7 +16,12 @@ * Used by: span list, span view, trace view, trace logs. */ -import { normalizeSlug, parseOrgProjectArg } from "./arg-parsing.js"; +import { + normalizeSlug, + type ParsedOrgProject, + parseOrgProjectArg, + parseOrgProjectInput, +} from "./arg-parsing.js"; import { ContextError, ValidationError } from "./errors.js"; import { handleRecoveryResult, @@ -86,6 +91,30 @@ export type ParsedTraceTarget = traceId: string; }; +/** + * A {@link ParsedTraceTarget} parsed without side effects, plus what the + * caller applies when it runs: the server of a pasted Sentry URL (for + * `applySentryUrlContext`) and warnings to log. + */ +export type TraceTargetInput = { + readonly parsed: ParsedTraceTarget; + readonly baseUrl?: string; + readonly warnings: readonly string[]; +}; + +/** Apply what parsing a trace target deferred, and return the target. */ +export function applyTraceTargetInput( + input: TraceTargetInput, +): ParsedTraceTarget { + if (input.baseUrl) { + applySentryUrlContext(input.baseUrl); + } + for (const warning of input.warnings) { + log.warn(warning); + } + return input.parsed; +} + /** Resolved trace target with both org and project. */ export type ResolvedTraceOrgProject = { traceId: string; @@ -128,6 +157,18 @@ export function parseTraceTarget( args: string[], usageHint: string, ): ParsedTraceTarget { + return applyTraceTargetInput(parseTraceTargetInput(args, usageHint)); +} + +/** + * Parse trace-related positional arguments like {@link parseTraceTarget}, + * without side effects: the server of a pasted Sentry URL and the warnings + * are returned for the caller to apply. + */ +export function parseTraceTargetInput( + args: readonly string[], + usageHint: string, +): TraceTargetInput { if (args.length === 0) { throw new ContextError("Trace ID", usageHint, []); } @@ -140,30 +181,30 @@ export function parseTraceTarget( // Sentry trace URLs (often copy-pasted with a trailing slash) carry the // trace ID and org in the path; slash-splitting them yields an empty or // bogus trace ID segment (CLI-13F). - const urlTarget = traceTargetFromUrl(first); + const urlTarget = traceTargetFromUrlInput(first); if (urlTarget) { - return urlTarget; + return { ...urlTarget, warnings: [] }; } // Warn about extra positional args that will be ignored - if (args.length > 2) { - log.warn( - `Extra arguments ignored: ${args.slice(2).join(" ")}. Expected: ${usageHint}`, - ); - } - - if (args.length === 1) { - return parseSlashSeparatedTraceTarget(first, usageHint); - } + const warnings = + args.length > 2 + ? [ + `Extra arguments ignored: ${args.slice(2).join(" ")}. Expected: ${usageHint}`, + ] + : []; // Two+ args: first is target context, second is trace ID const second = args[1]; if (second === undefined) { - return parseSlashSeparatedTraceTarget(first, usageHint); + return { + parsed: parseSlashSeparatedTraceTarget(first, usageHint), + warnings, + }; } const traceId = validateTraceId(second); - return targetArgToTraceTarget(first, traceId); + return { ...targetArgToTraceTargetInput(first, traceId), warnings }; } /** @@ -176,16 +217,30 @@ export function parseTraceTarget( * @internal Exported for testing */ export function traceTargetFromUrl(input: string): ParsedTraceTarget | null { + const urlTarget = traceTargetFromUrlInput(input); + if (!urlTarget) { + return null; + } + applySentryUrlContext(urlTarget.baseUrl); + return urlTarget.parsed; +} + +/** + * {@link traceTargetFromUrl} without side effects: returns the URL's server + * instead of applying it. + */ +function traceTargetFromUrlInput( + input: string, +): { parsed: ParsedTraceTarget; baseUrl: string } | null { const urlParsed = parseSentryUrl(input.trim()); if (!urlParsed?.traceId) { return null; } - applySentryUrlContext(urlParsed.baseUrl); const traceId = validateTraceId(urlParsed.traceId); - if (urlParsed.org) { - return { type: "org-scoped", traceId, org: urlParsed.org }; - } - return { type: "auto-detect", traceId }; + const parsed: ParsedTraceTarget = urlParsed.org + ? { type: "org-scoped", traceId, org: urlParsed.org } + : { type: "auto-detect", traceId }; + return { parsed, baseUrl: urlParsed.baseUrl }; } /** @@ -294,8 +349,29 @@ export function targetArgToTraceTarget( targetArg: string, traceId: string, ): ParsedTraceTarget { - const parsed = parseOrgProjectArg(targetArg); + const { parsed, baseUrl } = targetArgToTraceTargetInput(targetArg, traceId); + if (baseUrl) { + applySentryUrlContext(baseUrl); + } + return parsed; +} +/** + * {@link targetArgToTraceTarget} without side effects: returns the server of + * a pasted Sentry URL instead of applying it. + */ +function targetArgToTraceTargetInput( + targetArg: string, + traceId: string, +): { parsed: ParsedTraceTarget; baseUrl?: string } { + const { parsed, baseUrl } = parseOrgProjectInput(targetArg); + return { parsed: orgProjectToTraceTarget(parsed, traceId), baseUrl }; +} + +function orgProjectToTraceTarget( + parsed: ParsedOrgProject, + traceId: string, +): ParsedTraceTarget { switch (parsed.type) { case "explicit": return { @@ -686,6 +762,26 @@ export function parseDualModeArgs( args: string[], traceUsageHint: string, ): ParsedDualModeArgs { + const input = parseDualModeInput(args, traceUsageHint); + return input.mode === "trace" + ? { mode: "trace", parsed: applyTraceTargetInput(input.trace) } + : input; +} + +/** {@link ParsedDualModeArgs} parsed without side effects. */ +export type DualModeInput = + | { mode: "project"; target?: string } + | { mode: "trace"; trace: TraceTargetInput }; + +/** + * Disambiguate dual-mode arguments like {@link parseDualModeArgs}, without + * side effects: trace mode returns a {@link TraceTargetInput} for the caller + * to apply. + */ +export function parseDualModeInput( + args: readonly string[], + traceUsageHint: string, +): DualModeInput { if (args.length === 0) { return { mode: "project" }; } @@ -701,15 +797,15 @@ export function parseDualModeArgs( if (last && isTraceId(last)) { return { mode: "trace", - parsed: parseTraceTarget(args, traceUsageHint), + trace: parseTraceTargetInput(args, traceUsageHint), }; } } // Trace detail URL → trace mode (the URL itself carries the trace ID) - const urlTarget = traceTargetFromUrl(first); + const urlTarget = traceTargetFromUrlInput(first); if (urlTarget) { - return { mode: "trace", parsed: urlTarget }; + return { mode: "trace", trace: { ...urlTarget, warnings: [] } }; } // Single arg: check the tail segment (last part after "/", or entire arg) @@ -719,7 +815,7 @@ export function parseDualModeArgs( if (isTraceId(tail)) { return { mode: "trace", - parsed: parseTraceTarget(args, traceUsageHint), + trace: parseTraceTargetInput(args, traceUsageHint), }; } diff --git a/packages/cli/test/commands/log/list.test.ts b/packages/cli/test/commands/log/list.test.ts index edafd7370..0a4ff21e5 100644 --- a/packages/cli/test/commands/log/list.test.ts +++ b/packages/cli/test/commands/log/list.test.ts @@ -348,10 +348,7 @@ describe("listCommand.func — standard mode", () => { beforeEach(() => { listLogsSpy = vi.spyOn(apiClient, "listLogs"); - resolveOrgProjectSpy = vi.spyOn( - resolveTarget, - "resolveProjectBoundFromArg", - ); + resolveOrgProjectSpy = vi.spyOn(resolveTarget, "resolveProjectBoundTarget"); vi.spyOn(resolveTarget, "resolveLogProjectId").mockResolvedValue(undefined); withProgressSpy = vi .spyOn(polling, "withProgress") @@ -760,7 +757,7 @@ describe("listCommand.func — positional disambiguation", () => { .spyOn(apiClient, "listTraceLogs") .mockResolvedValue([]); resolveOrgProjectSpy = vi - .spyOn(resolveTarget, "resolveProjectBoundFromArg") + .spyOn(resolveTarget, "resolveProjectBoundTarget") .mockResolvedValue({ org: ORG, project: PROJECT }); resolveTraceOrgSpy = vi .spyOn(traceTarget, "resolveTraceOrg") @@ -1003,7 +1000,7 @@ describe("listCommand.func — flag validation", () => { // downstream resolution, not flag validation. Mock resolution to reject // with a non-ValidationError so we can verify flag validation passed. const resolveOrgProjectSpy = vi - .spyOn(resolveTarget, "resolveProjectBoundFromArg") + .spyOn(resolveTarget, "resolveProjectBoundTarget") .mockRejectedValueOnce( new ContextError("Organization", "sentry log list"), ); @@ -1035,10 +1032,7 @@ describe("listCommand.func — follow mode (standard)", () => { beforeEach(() => { sigint = interceptSigint(); listLogsSpy = vi.spyOn(apiClient, "listLogs"); - resolveOrgProjectSpy = vi.spyOn( - resolveTarget, - "resolveProjectBoundFromArg", - ); + resolveOrgProjectSpy = vi.spyOn(resolveTarget, "resolveProjectBoundTarget"); vi.spyOn(resolveTarget, "resolveLogProjectId").mockResolvedValue(undefined); isPlainSpy = vi.spyOn(formatters, "isPlainOutput").mockReturnValue(true); updateNotifSpy = vi diff --git a/packages/cli/test/commands/replay/download.test.ts b/packages/cli/test/commands/replay/download.test.ts index 6d9d6f492..3b2a672c6 100644 --- a/packages/cli/test/commands/replay/download.test.ts +++ b/packages/cli/test/commands/replay/download.test.ts @@ -112,7 +112,7 @@ describe("replay download", () => { vi.clearAllMocks(); tmpDir = await mkdtemp(join(tmpdir(), "replay-dl-")); resolveTargetSpy = vi - .spyOn(resolveTarget, "resolveOrgOptionalFromArg") + .spyOn(resolveTarget, "resolveOrgOptionalTarget") .mockResolvedValue({ org: "test-org" }); resolveReplaySpy = vi .spyOn(apiClient, "resolveReplay") @@ -189,7 +189,7 @@ describe("replay download", () => { ); expect(resolveTargetSpy).toHaveBeenCalledWith( - "url-org/", + { type: "org-all", org: "url-org" }, tmpDir, "replay download", ); diff --git a/packages/cli/test/commands/replay/view.test.ts b/packages/cli/test/commands/replay/view.test.ts index 38bd70435..8fd667824 100644 --- a/packages/cli/test/commands/replay/view.test.ts +++ b/packages/cli/test/commands/replay/view.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { parsePositionalArgs, + replayArguments, viewCommand, } from "../../../src/commands/replay/view.js"; @@ -38,6 +39,7 @@ import * as browser from "../../../src/lib/browser.js"; import { ApiError, ContextError, + HostScopeError, ResolutionError, ValidationError, } from "../../../src/lib/errors.js"; @@ -197,7 +199,7 @@ describe("viewCommand.func", () => { }, ], }); - resolveTargetSpy = vi.spyOn(resolveTarget, "resolveOrgOptionalFromArg"); + resolveTargetSpy = vi.spyOn(resolveTarget, "resolveOrgOptionalTarget"); openInBrowserSpy = vi.spyOn(browser, "openInBrowser").mockResolvedValue(); }); @@ -408,3 +410,36 @@ describe("viewCommand.func", () => { expect(output).toContain("5s"); }); }); + +describe("replayArguments", () => { + const positional = replayArguments("view", "sentry replay view "); + const context = { cwd: "/tmp" }; + + test("parses the replay ID and target without resolving", () => { + const target = positional.combine.call(context, [ + "test-org/cli", + REPLAY_ID, + ]); + expect(target.parsed).toEqual({ + replayId: REPLAY_ID, + target: { type: "explicit", org: "test-org", project: "cli" }, + baseUrl: undefined, + warning: undefined, + }); + }); + + test("rejects an invalid replay ID when parsing", () => { + expect(() => positional.combine.call(context, ["not-hex"])).toThrow( + ValidationError, + ); + }); + + test("applies a pasted URL's server when resolving, not when parsing", async () => { + const target = positional.combine.call(context, [ + `https://sentry.example.com/organizations/acme/explore/replays/${REPLAY_ID}/`, + ]); + expect(target.parsed.baseUrl).toBe("https://sentry.example.com"); + // No account for this server: applying it throws HostScopeError + await expect(target.resolved).rejects.toThrow(HostScopeError); + }); +}); diff --git a/packages/cli/test/lib/positional-parse.test.ts b/packages/cli/test/lib/positional-parse.test.ts index fe957bde3..7f77afa63 100644 --- a/packages/cli/test/lib/positional-parse.test.ts +++ b/packages/cli/test/lib/positional-parse.test.ts @@ -27,6 +27,10 @@ import { ValidationError, } from "../../src/lib/errors.js"; import { + applyBoundPositionals, + type BoundArguments, + BoundPositional, + boundArguments, checkDestructive, rawPositional, } from "../../src/lib/positional-parse.js"; @@ -247,3 +251,168 @@ describe("rawPositional", () => { expect(rawPositional(undefined)).toBeUndefined(); }); }); + +/** `[] `: what the first argument means depends on the count. */ +function parseIdArguments(raw: readonly string[]): { + target?: string; + id: string; +} { + if (raw.length === 0) { + throw new ContextError("ID", "test cmd [] ", []); + } + if (raw.length > 2) { + throw new ValidationError("Too many arguments"); + } + const [first = "", second] = raw; + return second === undefined ? { id: first } : { target: first, id: second }; +} + +const idResolver = vi.fn( + async ( + parsed: ReturnType, + { cwd }: { cwd: string }, + raw: readonly string[], + ) => ({ ...parsed, cwd, raw: [...raw] }), +); + +const idArguments = boundArguments({ + placeholder: "target-or-id", + brief: "[] ", + kind: ORG_PROJECT_TARGET, + parse: parseIdArguments, + resolve: idResolver, +}); + +type IdArguments = BoundArguments< + ReturnType, + Awaited> +>; + +/** Run `test cmd ...args` with {@link idArguments}, recording the result. */ +async function runWithArguments(args: string[]): Promise<{ + resolved: unknown[]; + stderr: string; + exitCode: number | undefined; +}> { + const resolved: unknown[] = []; + const command = buildCommand< + Record, + [IdArguments], + TestContext + >({ + docs: { brief: "Takes an ID" }, + auth: false, + parameters: { positional: idArguments }, + // oxlint-disable-next-line require-yield -- test command only records the resolved arguments + async *func(_flags, target) { + resolved.push(await target.resolved); + }, + }); + const app = buildApplication( + buildRouteMap({ routes: { cmd: command }, docs: { brief: "Test" } }), + { name: "test", determineExitCode: getExitCode }, + ); + let stderr = ""; + const stdout = { write: () => true }; + const context = { + cwd: "/work", + process: { + stdout, + stderr: { + write: (s: string) => { + stderr += s; + return true; + }, + }, + exitCode: undefined, + }, + stdout, + } as unknown as TestContext; + await run(app, ["cmd", ...args], context); + return { resolved, stderr, exitCode: context.process.exitCode }; +} + +describe("boundArguments", () => { + test("parses every argument at once, so the count decides what each means", async () => { + expect((await runWithArguments(["abc"])).resolved).toEqual([ + { id: "abc", cwd: "/work", raw: ["abc"] }, + ]); + expect((await runWithArguments(["acme/web", "abc"])).resolved).toEqual([ + { target: "acme/web", id: "abc", cwd: "/work", raw: ["acme/web", "abc"] }, + ]); + }); + + test("a parse error fails as an argument parse error", async () => { + const { resolved, stderr, exitCode } = await runWithArguments([ + "a", + "b", + "c", + ]); + expect(resolved).toEqual([]); + expect(exitCode).toBe(ExitCode.InvalidArgument); + expect(stderr).toContain('Failed to parse "a b c" for target-or-id'); + expect(stderr).toContain("Too many arguments"); + }); + + test("a parse error next to `help` fails when func would run instead", async () => { + idResolver.mockClear(); + const { resolved, stderr, exitCode } = await runWithArguments([ + "help", + "a", + "b", + ]); + expect(resolved).toEqual([]); + expect(idResolver).not.toHaveBeenCalled(); + // Thrown by the command wrapper, where `help` recovery can catch it, + // rather than by Stricli's scanner + expect(stderr).not.toContain("Failed to parse"); + expect(stderr).toContain("Too many arguments"); + expect(exitCode).not.toBe(ExitCode.InvalidArgument); + }); + + test("parses no arguments too", async () => { + const { resolved, exitCode, stderr } = await runWithArguments([]); + expect(resolved).toEqual([]); + expect(exitCode).toBe(ExitCode.InvalidArgument); + expect(stderr).toContain("ID is required"); + }); + + test("raw strings (SDK invoke, tests) become one bound value", async () => { + idResolver.mockClear(); + const context = { cwd: "/work" }; + const [value, ...rest] = applyBoundPositionals( + ["acme/web", "abc"], + idArguments, + context, + ); + expect(rest).toEqual([]); + expect(value).toBeInstanceOf(BoundPositional); + const bound = value as IdArguments; + expect(bound.raw).toEqual(["acme/web", "abc"]); + expect(bound.parsed).toEqual({ target: "acme/web", id: "abc" }); + expect(idResolver).not.toHaveBeenCalled(); + await expect(bound.resolved).resolves.toMatchObject({ cwd: "/work" }); + + // A value Stricli already combined is kept + expect(applyBoundPositionals([bound], idArguments, context)).toEqual([ + bound, + ]); + }); + + test("raw strings that fail to parse throw the parser's error", () => { + expect(() => applyBoundPositionals([], idArguments, {})).toThrow( + ContextError, + ); + }); + + test("rawPositional returns every argument", () => { + const [bound] = applyBoundPositionals(["abc"], idArguments, {}); + expect(rawPositional(bound)).toEqual(["abc"]); + }); + + test("each argument completes as the given kind", () => { + expect(getCompletionTarget(idArguments.parameter.parse)).toBe( + ORG_PROJECT_TARGET, + ); + }); +}); diff --git a/packages/cli/test/lib/trace-target.test.ts b/packages/cli/test/lib/trace-target.test.ts index 8e105a803..dff3264cb 100644 --- a/packages/cli/test/lib/trace-target.test.ts +++ b/packages/cli/test/lib/trace-target.test.ts @@ -28,8 +28,10 @@ import { import { extractRawTraceId, parseDualModeArgs, + parseDualModeInput, parseSlashSeparatedTraceTarget, parseTraceTarget, + parseTraceTargetInput, resolveTraceOrg, resolveTraceOrgOptionalProject, resolveTraceOrgProject, @@ -387,3 +389,49 @@ describe("trailing slashes and Sentry trace URLs (CLI-13F)", () => { expect(result.mode).toBe("trace"); }); }); + +describe("parsing without side effects", () => { + const HINT = "sentry log list [/[/]]"; + const SELF_HOSTED_URL = `https://sentry.example.com/organizations/acme/traces/${VALID_TRACE_ID}/`; + let savedUrl: string | undefined; + + beforeEach(() => { + savedUrl = process.env.SENTRY_URL; + }); + + afterEach(() => { + if (savedUrl === undefined) { + delete process.env.SENTRY_URL; + } else { + process.env.SENTRY_URL = savedUrl; + } + }); + + test("a pasted URL's server is returned, not applied", () => { + // Applying a server with no account throws HostScopeError + expect(parseTraceTargetInput([SELF_HOSTED_URL], HINT)).toEqual({ + parsed: { type: "org-scoped", traceId: VALID_TRACE_ID, org: "acme" }, + baseUrl: "https://sentry.example.com", + warnings: [], + }); + expect(parseDualModeInput([SELF_HOSTED_URL], HINT)).toMatchObject({ + mode: "trace", + trace: { baseUrl: "https://sentry.example.com" }, + }); + expect(process.env.SENTRY_URL).toBe(savedUrl); + }); + + test("ignored extra arguments are returned as a warning", () => { + expect( + parseTraceTargetInput(["acme/web", VALID_TRACE_ID, "extra"], HINT), + ).toEqual({ + parsed: { + type: "explicit", + traceId: VALID_TRACE_ID, + org: "acme", + project: "web", + }, + warnings: [`Extra arguments ignored: extra. Expected: ${HINT}`], + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 303983414..da2b10417 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -203,7 +203,7 @@ packageExtensionsChecksum: sha256-PIoL2jn0sFpFHgcMKW97dNeqBXVNxH6qFIzsWIBFOMY= patchedDependencies: '@ai-sdk/provider-utils@2.2.8': 4649a74595006c4a262d453549e091db331ade06b4f4320957af1b2c659b8ee3 '@cloudflare/workers-oauth-provider@0.10.3': 34910b67929c1888b1492384c514d6baeedc4a439f66e21375dee5af404834f8 - '@stricli/core@1.2.8': cbf047581d38be04108d3921321c14a778272c24004da8110d4498d4f2a6b2e1 + '@stricli/core@1.2.8': a9904d4240892b55b3a1973098ead2c5df8af57cd65290000b095ee4a14f4794 importers: @@ -497,7 +497,7 @@ importers: version: 1.3.0 '@stricli/core': specifier: 1.2.8 - version: 1.2.8(patch_hash=cbf047581d38be04108d3921321c14a778272c24004da8110d4498d4f2a6b2e1) + version: 1.2.8(patch_hash=a9904d4240892b55b3a1973098ead2c5df8af57cd65290000b095ee4a14f4794) '@types/http-cache-semantics': specifier: ^4.2.0 version: 4.2.0 @@ -13149,7 +13149,7 @@ snapshots: dependencies: '@stricli/core': 1.3.0 - '@stricli/core@1.2.8(patch_hash=cbf047581d38be04108d3921321c14a778272c24004da8110d4498d4f2a6b2e1)': {} + '@stricli/core@1.2.8(patch_hash=a9904d4240892b55b3a1973098ead2c5df8af57cd65290000b095ee4a14f4794)': {} '@stricli/core@1.3.0': {} @@ -14902,7 +14902,7 @@ snapshots: fossilize@0.10.1: dependencies: '@stricli/auto-complete': 1.3.0 - '@stricli/core': 1.2.8(patch_hash=cbf047581d38be04108d3921321c14a778272c24004da8110d4498d4f2a6b2e1) + '@stricli/core': 1.2.8(patch_hash=a9904d4240892b55b3a1973098ead2c5df8af57cd65290000b095ee4a14f4794) binpunch: 1.0.0 esbuild: 0.25.12 macho-unsign: 2.0.6 From 1fa4eff937eb6bc3bb15ea53d513ce797c03fff4 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:01:20 +0000 Subject: [PATCH 02/13] fix(cli): Skip a subcommand name before parsing bound arguments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Bugbot on #1714. `buildListCommand` dropped a subcommand name (`sentry log list view …`) in `func` and parsed the other arguments again, after the scanner had already parsed all of them: - `log list view acme/web ` failed in the scanner (exit 252), because "acme/web" was read as the trace ID, so the intercept never ran - a parse error deferred next to `help` in the second parse was never thrown, so `func` read an undefined parse `skipLeadingArgument` now wraps a `boundArguments` positional so the name is left out in the scanner, before it changes what the other arguments mean. `func` only prints the hint. --- packages/cli/src/lib/list-command.ts | 65 +++++++++++-------- packages/cli/src/lib/positional-parse.ts | 44 +++++++++++++ .../cli/test/lib/positional-parse.test.ts | 36 +++++++++- 3 files changed, 117 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/lib/list-command.ts b/packages/cli/src/lib/list-command.ts index 415d66dd5..7d1407a6c 100644 --- a/packages/cli/src/lib/list-command.ts +++ b/packages/cli/src/lib/list-command.ts @@ -44,7 +44,12 @@ import { type ListResult, type OrgListConfig, } from "./org-list.js"; -import { applyBoundPositionals, rawPositional } from "./positional-parse.js"; +import { + applyBoundPositionals, + rawPositional, + skipLeadingArgument, + skippedArgument, +} from "./positional-parse.js"; import { disableResponseCache } from "./response-cache.js"; import { PERIOD_BRIEF, parsePeriod } from "./time-range.js"; import { shellQuote } from "./utils.js"; @@ -533,19 +538,21 @@ export function interceptSubcommand( stderr: { write(s: string): void }, routeName: string, ): string | undefined { - if (!target) { + if (!(target && isSubcommandName(target, routeName))) { return target; } - const trimmed = target.trim(); - if (trimmed && getSubcommandsForRoute(routeName).has(trimmed)) { - stderr.write( - warning( - `Tip: "${trimmed}" is a subcommand. Running: sentry ${routeName} ${trimmed}\n`, - ), - ); - return; - } - return target; + stderr.write( + warning( + `Tip: "${target.trim()}" is a subcommand. Running: sentry ${routeName} ${target.trim()}\n`, + ), + ); + return; +} + +/** Whether `arg` names a subcommand of `routeName` (see {@link interceptSubcommand}). */ +function isSubcommandName(arg: string, routeName: string): boolean { + const trimmed = arg.trim(); + return trimmed !== "" && getSubcommandsForRoute(routeName).has(trimmed); } // --------------------------------------------------------------------------- @@ -652,8 +659,14 @@ export function buildListCommand< mergedAliases.c = "cursor"; } + // `boundArguments` positionals parse every argument in the scanner, so a + // subcommand name is left out there, before it changes what the rest mean + const positional = skipLeadingArgument(params.positional, (arg) => + isSubcommandName(arg, routeName), + ); const mergedParams = { ...params, + ...(positional ? { positional } : {}), flags: mergedFlags, aliases: mergedAliases, }; @@ -665,22 +678,22 @@ export function buildListCommand< // The first positional arg is always the target (org/project pattern), // either a raw string or a parsed value. Intercept it to handle plural - // alias confusion. - const raw = rawPositional(args[0]); - const rawTarget = Array.isArray(raw) ? raw[0] : raw; - let remaining = args; - if (typeof rawTarget === "string") { - // All list commands use SentryContext which has stderr at top level - const ctx = this as unknown as { stderr: { write(s: string): void } }; - if (interceptSubcommand(rawTarget, ctx.stderr, routeName) === undefined) { - // Bound arguments are parsed again without it - remaining = Array.isArray(raw) - ? raw.slice(1) - : [undefined, ...args.slice(1)]; - } + // alias confusion. All list commands use SentryContext which has stderr + // at top level + const ctx = this as unknown as { stderr: { write(s: string): void } }; + const skipped = skippedArgument(args[0]); + const rawTarget = rawPositional(args[0]); + if (skipped !== undefined) { + // The scanner already parsed the other arguments without it + interceptSubcommand(skipped, ctx.stderr, routeName); + } else if ( + typeof rawTarget === "string" && + interceptSubcommand(rawTarget, ctx.stderr, routeName) === undefined + ) { + args[0] = undefined; } // A dropped bound target becomes an omitted one (auto-detect) - const finalArgs = applyBoundPositionals(remaining, params.positional, this); + const finalArgs = applyBoundPositionals(args, positional, this); return originalFunc.call(this, flags, ...(finalArgs as unknown as ARGS)); } as typeof originalFunc; diff --git a/packages/cli/src/lib/positional-parse.ts b/packages/cli/src/lib/positional-parse.ts index cae3b9275..981343d4d 100644 --- a/packages/cli/src/lib/positional-parse.ts +++ b/packages/cli/src/lib/positional-parse.ts @@ -45,6 +45,9 @@ const boundParsers = new WeakSet(); /** `combine` functions made by {@link boundArguments}. */ const argumentCombiners = new WeakSet(); +/** Leading arguments {@link skipLeadingArgument} left out, by bound value. */ +const skippedArguments = new WeakMap(); + /** Parse errors of {@link boundArguments} values, thrown when `func` runs. */ const deferredParseErrors = new WeakMap(); @@ -188,6 +191,47 @@ export function boundArguments(options: { }; } +/** + * Wrap a {@link boundArguments} positional so a leading argument that + * `skip` matches is left out before the arguments are parsed; + * {@link skippedArgument} returns it. Other positionals are returned as + * they are. + */ +export function skipLeadingArgument

( + positional: P, + skip: (arg: string) => boolean, +): P { + const def = positional as PositionalDef | undefined; + if ( + !( + def?.kind === "array" && + def.combine && + argumentCombiners.has(def.combine) + ) + ) { + return positional; + } + const combineAll = def.combine; + const combine = function (this: unknown, values: readonly string[]) { + const [first, ...rest] = values; + if (first === undefined || !skip(first)) { + return combineAll.call(this, values); + } + const bound = combineAll.call(this, rest) as object; + skippedArguments.set(bound, first); + return bound; + }; + argumentCombiners.add(combine); + return { ...positional, combine }; +} + +/** The argument {@link skipLeadingArgument} left out of `value`, if any. */ +export function skippedArgument(value: unknown): string | undefined { + return value instanceof BoundPositional + ? skippedArguments.get(value) + : undefined; +} + /** A Stricli positional definition — the subset that carries the parsers. */ type PositionalDef = | { kind: "tuple"; parameters: readonly { parse?: unknown }[] } diff --git a/packages/cli/test/lib/positional-parse.test.ts b/packages/cli/test/lib/positional-parse.test.ts index 7f77afa63..547217a31 100644 --- a/packages/cli/test/lib/positional-parse.test.ts +++ b/packages/cli/test/lib/positional-parse.test.ts @@ -33,6 +33,9 @@ import { boundArguments, checkDestructive, rawPositional, + skipLeadingArgument, + skippedArgument, + throwDeferredParseErrors, } from "../../src/lib/positional-parse.js"; import { buildRouteMap } from "../../src/lib/route-map.js"; import { @@ -289,7 +292,10 @@ type IdArguments = BoundArguments< >; /** Run `test cmd ...args` with {@link idArguments}, recording the result. */ -async function runWithArguments(args: string[]): Promise<{ +async function runWithArguments( + args: string[], + positional: typeof idArguments = idArguments, +): Promise<{ resolved: unknown[]; stderr: string; exitCode: number | undefined; @@ -302,7 +308,7 @@ async function runWithArguments(args: string[]): Promise<{ >({ docs: { brief: "Takes an ID" }, auth: false, - parameters: { positional: idArguments }, + parameters: { positional }, // oxlint-disable-next-line require-yield -- test command only records the resolved arguments async *func(_flags, target) { resolved.push(await target.resolved); @@ -410,6 +416,32 @@ describe("boundArguments", () => { expect(rawPositional(bound)).toEqual(["abc"]); }); + test("skipLeadingArgument leaves a matching first argument out before parsing", async () => { + const skipping = skipLeadingArgument(idArguments, (arg) => arg === "view"); + // Parsed in the scanner without "view", so the count still decides + expect( + (await runWithArguments(["view", "acme/web", "abc"], skipping)).resolved, + ).toEqual([ + { target: "acme/web", id: "abc", cwd: "/work", raw: ["acme/web", "abc"] }, + ]); + + const [bound] = applyBoundPositionals(["view", "abc"], skipping, {}); + expect(skippedArgument(bound)).toBe("view"); + expect((bound as IdArguments).parsed).toEqual({ id: "abc" }); + const [kept] = applyBoundPositionals(["abc"], skipping, {}); + expect(skippedArgument(kept)).toBeUndefined(); + }); + + test("skipLeadingArgument keeps a parse error next to `help` deferred", () => { + const skipping = skipLeadingArgument(idArguments, (arg) => arg === "view"); + const args = applyBoundPositionals( + ["view", "help", "a", "b"], + skipping, + {}, + ); + expect(() => throwDeferredParseErrors(args)).toThrow("Too many arguments"); + }); + test("each argument completes as the given kind", () => { expect(getCompletionTarget(idArguments.parameter.parse)).toBe( ORG_PROJECT_TARGET, From 5bc364c8778a86dc6b0428a94c19b49e77af0322 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:16:55 +0000 Subject: [PATCH 03/13] ref(cli): Parse bound arguments without patching Stricli MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the array positional `combine` hook from our `@stricli/core` patch. `buildCommand` already runs `applyBoundPositionals` before `func`, so it now parses `boundArguments` positionals there for every caller, CLI and SDK alike. A parse error becomes Stricli's own `ArgumentParseError`. `getExitCode` maps it to `InvalidArgument` and `exceptionWhileRunningCommand` formats it as an argument error, so it still prints `Failed to parse "…" for ` and exits 252, before the auth guard. Next to a `help` argument it is thrown inside the wrapper's try instead, so the help recovery catches it. That replaces the deferred-error bookkeeping. The patch, its `check:patches` markers and the lockfile hash are back to main's. Co-Authored-By: BYK --- packages/cli/AGENTS.md | 8 +- .../cli/patches/@stricli%2Fcore@1.2.8.patch | 166 +++--------------- packages/cli/script/check-patches.ts | 19 -- packages/cli/src/app.ts | 7 + packages/cli/src/lib/command.ts | 44 +++-- packages/cli/src/lib/errors.ts | 9 +- packages/cli/src/lib/list-command.ts | 5 +- packages/cli/src/lib/positional-parse.ts | 144 +++++++-------- .../cli/test/commands/replay/download.test.ts | 10 +- .../cli/test/commands/replay/view.test.ts | 20 ++- packages/cli/test/lib/help-positional.test.ts | 28 +++ .../cli/test/lib/positional-parse.test.ts | 43 ++--- pnpm-lock.yaml | 8 +- 13 files changed, 210 insertions(+), 301 deletions(-) diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 31c7faed7..bb39eece1 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -277,9 +277,11 @@ trailing trace ID), declare the positional with `boundArguments` from once and whose `resolve` binds like a target positional's: `func` takes one `BoundArguments` (alias it per command and add the alias to the lint rule's type list), reads `target.parsed`, and awaits -`target.resolved`. Stricli runs the parse in its scanner through the -`combine` hook our `@stricli/core` patch adds, so errors exit 252 like any -other argument. The same purity rule applies: return a pasted URL's server +`target.resolved`. A Stricli parser sees one argument at a time, so +`buildCommand` runs the parse before `func` (and before the auth guard). A +parse error becomes Stricli's `ArgumentParseError`, which prints and exits +252 like a scanner error; next to a `help` argument it turns into the +command's help instead. The same purity rule applies: return a pasted URL's server (`baseUrl`) and any warnings from the parse, and apply them in `resolve` (`parseTraceTargetInput` and `parseDualModeInput` in `trace-target.ts` do this for trace targets). diff --git a/packages/cli/patches/@stricli%2Fcore@1.2.8.patch b/packages/cli/patches/@stricli%2Fcore@1.2.8.patch index 05044c4a5..c8256f2a8 100644 --- a/packages/cli/patches/@stricli%2Fcore@1.2.8.patch +++ b/packages/cli/patches/@stricli%2Fcore@1.2.8.patch @@ -1,49 +1,8 @@ diff --git a/dist/index.cjs b/dist/index.cjs -index a9afbc1ab78cad7ba97d53882a80236af62854df..39cfc8dd597cbf3c3cd4ac1665bed4246fcb15cf 100644 +index a9afbc1ab78cad7ba97d53882a80236af62854df..141532779be923b8f3b421b0afc031b5ea209e29 100644 --- a/dist/index.cjs +++ b/dist/index.cjs -@@ -406,6 +406,20 @@ var ArgumentParseError = class extends ArgumentScannerError { - this.exception = exception; - } - }; -+async function combinePositionalValues(positional, values_p, inputs, context) { -+ const values = await values_p; -+ if (values.status === "rejected") { -+ return values; -+ } -+ try { -+ return { status: "fulfilled", value: [await positional.combine.call(context, values.value)] }; -+ } catch (exc) { -+ return { -+ status: "rejected", -+ reasons: [new ArgumentParseError(getPlaceholder(positional.parameter), inputs.join(" "), exc)] -+ }; -+ } -+} - function parseInput(externalFlagNameOrPlaceholder, parameter, input, context) { - try { - return parameter.parse.call(context, input); -@@ -880,6 +894,19 @@ function buildArgumentScanner(parameters, config) { - return parseInput(placeholder, positional.parameter, input, context); - }) - ); -+ // PATCH(getsentry/cli): optional `combine` for array positionals. -+ // Some commands read their arguments as a whole: what each one means -+ // depends on how many were given (e.g. `[] `), which a -+ // per-element `parse` cannot see. When the array positional declares -+ // `combine`, it receives the parsed elements (and the command context -+ // as `this`) and returns the single value the command receives as its -+ // only positional argument. A throw becomes an ArgumentParseError for -+ // the joined inputs, like any other parse failure. The option is inert -+ // when unset, so stock Stricli behavior is unchanged. If -+ // `combinePositionalValues` is missing, the CLI's `check:patches` fails. -+ if (typeof positional.combine === "function") { -+ positionalValues_p = combinePositionalValues(positional, positionalValues_p, positionalInputs, context); -+ } - } else { - positionalValues_p = allSettledOrElse( - positional.parameters.map(async (param, i) => { -@@ -1277,6 +1304,35 @@ var RouteMapSymbol = Symbol("RouteMap"); +@@ -1277,6 +1277,35 @@ var RouteMapSymbol = Symbol("RouteMap"); var CommandSymbol = Symbol("Command"); // src/routing/scanner.ts @@ -79,7 +38,7 @@ index a9afbc1ab78cad7ba97d53882a80236af62854df..39cfc8dd597cbf3c3cd4ac1665bed424 function buildRouteScanner(root, config, startingPrefix) { const prefix = [...startingPrefix]; const unprocessedInputs = []; -@@ -1285,7 +1341,14 @@ function buildRouteScanner(root, config, startingPrefix) { +@@ -1285,7 +1314,14 @@ function buildRouteScanner(root, config, startingPrefix) { let target; let rootLevel = true; let helpRequested = false; @@ -94,7 +53,7 @@ index a9afbc1ab78cad7ba97d53882a80236af62854df..39cfc8dd597cbf3c3cd4ac1665bed424 return { next: (input) => { if (!treatInputsAsArguments && config.allowArgumentEscapeSequence && input === "--") { -@@ -1293,6 +1356,14 @@ function buildRouteScanner(root, config, startingPrefix) { +@@ -1293,6 +1329,14 @@ function buildRouteScanner(root, config, startingPrefix) { unprocessedInputs.push(input); return; } @@ -109,7 +68,7 @@ index a9afbc1ab78cad7ba97d53882a80236af62854df..39cfc8dd597cbf3c3cd4ac1665bed424 if (!treatInputsAsArguments) { if (input === "--help" || input === "-h") { helpRequested = true; -@@ -1300,12 +1371,28 @@ function buildRouteScanner(root, config, startingPrefix) { +@@ -1300,12 +1341,28 @@ function buildRouteScanner(root, config, startingPrefix) { target = current; } return; @@ -139,7 +98,7 @@ index a9afbc1ab78cad7ba97d53882a80236af62854df..39cfc8dd597cbf3c3cd4ac1665bed424 } } if (target) { -@@ -1357,6 +1444,7 @@ function buildRouteScanner(root, config, startingPrefix) { +@@ -1357,6 +1414,7 @@ function buildRouteScanner(root, config, startingPrefix) { target, unprocessedInputs, helpRequested, @@ -147,7 +106,7 @@ index a9afbc1ab78cad7ba97d53882a80236af62854df..39cfc8dd597cbf3c3cd4ac1665bed424 prefix, rootLevel, aliases -@@ -1410,16 +1498,6 @@ async function runApplication({ root, defaultText, config }, rawInputs, context) +@@ -1410,16 +1468,6 @@ async function runApplication({ root, defaultText, config }, rawInputs, context) } } const inputs = rawInputs.slice(); @@ -164,7 +123,7 @@ index a9afbc1ab78cad7ba97d53882a80236af62854df..39cfc8dd597cbf3c3cd4ac1665bed424 const scanner = buildRouteScanner(root, config.scanner, [config.name]); let error; while (inputs.length > 0 && !error) { -@@ -1445,7 +1523,37 @@ async function runApplication({ root, defaultText, config }, rawInputs, context) +@@ -1445,7 +1493,37 @@ async function runApplication({ root, defaultText, config }, rawInputs, context) return ExitCode.UnknownCommand; } const result = scanner.finish(); @@ -202,7 +161,7 @@ index a9afbc1ab78cad7ba97d53882a80236af62854df..39cfc8dd597cbf3c3cd4ac1665bed424 const ansiColor = shouldUseAnsiColor(context.process, context.process.stdout, config.documentation); context.process.stdout.write( result.target.formatHelp({ -@@ -1514,6 +1622,9 @@ function withDefaults(config) { +@@ -1514,6 +1592,9 @@ function withDefaults(config) { const scannerConfig = { caseStyle: scannerCaseStyle, allowArgumentEscapeSequence: config.scanner?.allowArgumentEscapeSequence ?? false, @@ -212,7 +171,7 @@ index a9afbc1ab78cad7ba97d53882a80236af62854df..39cfc8dd597cbf3c3cd4ac1665bed424 distanceOptions: config.scanner?.distanceOptions ?? { threshold: 7, weights: { -@@ -1529,7 +1640,10 @@ function withDefaults(config) { +@@ -1529,7 +1610,10 @@ function withDefaults(config) { useAliasInUsageLine: config.documentation?.useAliasInUsageLine ?? false, onlyRequiredInUsageLine: config.documentation?.onlyRequiredInUsageLine ?? false, caseStyle: displayCaseStyle, @@ -224,7 +183,7 @@ index a9afbc1ab78cad7ba97d53882a80236af62854df..39cfc8dd597cbf3c3cd4ac1665bed424 }; const completionConfig = { includeAliases: config.completion?.includeAliases ?? documentationConfig.useAliasInUsageLine, -@@ -1881,7 +1995,7 @@ function formatDocumentationForFlagParameters(flags, aliases, args) { +@@ -1881,7 +1965,7 @@ function formatDocumentationForFlagParameters(flags, aliases, args) { if (args.includeHelpAllFlag) { const helpAllFlagName = formatForDisplay("helpAll", args.config.caseStyle); rows.push({ @@ -233,7 +192,7 @@ index a9afbc1ab78cad7ba97d53882a80236af62854df..39cfc8dd597cbf3c3cd4ac1665bed424 flagName: atLeastOneOptional ? ` --${helpAllFlagName}` : `--${helpAllFlagName}`, brief: briefs.helpAll, hidden: !args.config.alwaysShowHelpAllFlag -@@ -1920,7 +2034,7 @@ function* generateBuiltInFlagUsageLines(args) { +@@ -1920,7 +2004,7 @@ function* generateBuiltInFlagUsageLines(args) { yield args.config.useAliasInUsageLine ? "-h" : "--help"; if (args.includeHelpAllFlag) { const helpAllFlagName = formatForDisplay("helpAll", args.config.caseStyle); @@ -242,7 +201,7 @@ index a9afbc1ab78cad7ba97d53882a80236af62854df..39cfc8dd597cbf3c3cd4ac1665bed424 } if (args.includeVersionFlag) { yield args.config.useAliasInUsageLine ? "-v" : "--version"; -@@ -2057,7 +2171,7 @@ function checkForInvalidVariadicSeparators(flags) { +@@ -2057,7 +2141,7 @@ function checkForInvalidVariadicSeparators(flags) { function buildCommand(builderArgs) { const { flags = {}, aliases = {} } = builderArgs.parameters; checkForReservedFlags(flags, ["help", "helpAll", "help-all"]); @@ -252,7 +211,7 @@ index a9afbc1ab78cad7ba97d53882a80236af62854df..39cfc8dd597cbf3c3cd4ac1665bed424 checkForInvalidVariadicSeparators(flags); let loader; diff --git a/dist/index.d.cts b/dist/index.d.cts -index 111d1fd9e926f23b71359a692cd2e9e05ebf379e..ff53a12a8477e79580d62145134c25f6e9da923e 100644 +index 111d1fd9e926f23b71359a692cd2e9e05ebf379e..1714047c8cf7d46e38062bab670451851709a7e2 100644 --- a/dist/index.d.cts +++ b/dist/index.d.cts @@ -402,6 +402,25 @@ interface ScannerConfiguration { @@ -310,22 +269,8 @@ index 111d1fd9e926f23b71359a692cd2e9e05ebf379e..ff53a12a8477e79580d62145134c25f6 } /** * Configuration for controlling the behavior of completion proposals. -@@ -993,6 +1034,13 @@ interface PositionalParameterArray { - readonly parameter: TypedPositionalParameter; - readonly minimum?: number; - readonly maximum?: number; -+ /** -+ * PATCH(getsentry/cli): reads the parsed arguments as a whole and returns -+ * the single value the command receives as its only positional argument. -+ * Use it when what each argument means depends on how many were given. -+ * A throw fails like a parse error for the joined inputs. -+ */ -+ readonly combine?: (this: CONTEXT, values: readonly T[]) => unknown; - } - type PositionalParametersForTuple = { - readonly [K in keyof T]: TypedPositionalParameter; diff --git a/dist/index.d.ts b/dist/index.d.ts -index 111d1fd9e926f23b71359a692cd2e9e05ebf379e..ff53a12a8477e79580d62145134c25f6e9da923e 100644 +index 111d1fd9e926f23b71359a692cd2e9e05ebf379e..1714047c8cf7d46e38062bab670451851709a7e2 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -402,6 +402,25 @@ interface ScannerConfiguration { @@ -383,66 +328,11 @@ index 111d1fd9e926f23b71359a692cd2e9e05ebf379e..ff53a12a8477e79580d62145134c25f6 } /** * Configuration for controlling the behavior of completion proposals. -@@ -993,6 +1034,13 @@ interface PositionalParameterArray { - readonly parameter: TypedPositionalParameter; - readonly minimum?: number; - readonly maximum?: number; -+ /** -+ * PATCH(getsentry/cli): reads the parsed arguments as a whole and returns -+ * the single value the command receives as its only positional argument. -+ * Use it when what each argument means depends on how many were given. -+ * A throw fails like a parse error for the joined inputs. -+ */ -+ readonly combine?: (this: CONTEXT, values: readonly T[]) => unknown; - } - type PositionalParametersForTuple = { - readonly [K in keyof T]: TypedPositionalParameter; diff --git a/dist/index.js b/dist/index.js -index f76639637e812453d107bda3e3ec165fc195f4d8..b4b98087a21dd4566b100758fda1f2be861ce3c4 100644 +index f76639637e812453d107bda3e3ec165fc195f4d8..eff534dc79cdbe5bf8ee14839157b6d87c52de17 100644 --- a/dist/index.js +++ b/dist/index.js -@@ -358,6 +358,20 @@ var ArgumentParseError = class extends ArgumentScannerError { - this.exception = exception; - } - }; -+async function combinePositionalValues(positional, values_p, inputs, context) { -+ const values = await values_p; -+ if (values.status === "rejected") { -+ return values; -+ } -+ try { -+ return { status: "fulfilled", value: [await positional.combine.call(context, values.value)] }; -+ } catch (exc) { -+ return { -+ status: "rejected", -+ reasons: [new ArgumentParseError(getPlaceholder(positional.parameter), inputs.join(" "), exc)] -+ }; -+ } -+} - function parseInput(externalFlagNameOrPlaceholder, parameter, input, context) { - try { - return parameter.parse.call(context, input); -@@ -832,6 +846,19 @@ function buildArgumentScanner(parameters, config) { - return parseInput(placeholder, positional.parameter, input, context); - }) - ); -+ // PATCH(getsentry/cli): optional `combine` for array positionals. -+ // Some commands read their arguments as a whole: what each one means -+ // depends on how many were given (e.g. `[] `), which a -+ // per-element `parse` cannot see. When the array positional declares -+ // `combine`, it receives the parsed elements (and the command context -+ // as `this`) and returns the single value the command receives as its -+ // only positional argument. A throw becomes an ArgumentParseError for -+ // the joined inputs, like any other parse failure. The option is inert -+ // when unset, so stock Stricli behavior is unchanged. If -+ // `combinePositionalValues` is missing, the CLI's `check:patches` fails. -+ if (typeof positional.combine === "function") { -+ positionalValues_p = combinePositionalValues(positional, positionalValues_p, positionalInputs, context); -+ } - } else { - positionalValues_p = allSettledOrElse( - positional.parameters.map(async (param, i) => { -@@ -1229,6 +1256,35 @@ var RouteMapSymbol = Symbol("RouteMap"); +@@ -1229,6 +1229,35 @@ var RouteMapSymbol = Symbol("RouteMap"); var CommandSymbol = Symbol("Command"); // src/routing/scanner.ts @@ -478,7 +368,7 @@ index f76639637e812453d107bda3e3ec165fc195f4d8..b4b98087a21dd4566b100758fda1f2be function buildRouteScanner(root, config, startingPrefix) { const prefix = [...startingPrefix]; const unprocessedInputs = []; -@@ -1237,7 +1293,14 @@ function buildRouteScanner(root, config, startingPrefix) { +@@ -1237,7 +1266,14 @@ function buildRouteScanner(root, config, startingPrefix) { let target; let rootLevel = true; let helpRequested = false; @@ -493,7 +383,7 @@ index f76639637e812453d107bda3e3ec165fc195f4d8..b4b98087a21dd4566b100758fda1f2be return { next: (input) => { if (!treatInputsAsArguments && config.allowArgumentEscapeSequence && input === "--") { -@@ -1245,6 +1308,14 @@ function buildRouteScanner(root, config, startingPrefix) { +@@ -1245,6 +1281,14 @@ function buildRouteScanner(root, config, startingPrefix) { unprocessedInputs.push(input); return; } @@ -508,7 +398,7 @@ index f76639637e812453d107bda3e3ec165fc195f4d8..b4b98087a21dd4566b100758fda1f2be if (!treatInputsAsArguments) { if (input === "--help" || input === "-h") { helpRequested = true; -@@ -1252,12 +1323,28 @@ function buildRouteScanner(root, config, startingPrefix) { +@@ -1252,12 +1293,28 @@ function buildRouteScanner(root, config, startingPrefix) { target = current; } return; @@ -538,7 +428,7 @@ index f76639637e812453d107bda3e3ec165fc195f4d8..b4b98087a21dd4566b100758fda1f2be } } if (target) { -@@ -1309,6 +1396,7 @@ function buildRouteScanner(root, config, startingPrefix) { +@@ -1309,6 +1366,7 @@ function buildRouteScanner(root, config, startingPrefix) { target, unprocessedInputs, helpRequested, @@ -546,7 +436,7 @@ index f76639637e812453d107bda3e3ec165fc195f4d8..b4b98087a21dd4566b100758fda1f2be prefix, rootLevel, aliases -@@ -1362,16 +1450,6 @@ async function runApplication({ root, defaultText, config }, rawInputs, context) +@@ -1362,16 +1420,6 @@ async function runApplication({ root, defaultText, config }, rawInputs, context) } } const inputs = rawInputs.slice(); @@ -563,7 +453,7 @@ index f76639637e812453d107bda3e3ec165fc195f4d8..b4b98087a21dd4566b100758fda1f2be const scanner = buildRouteScanner(root, config.scanner, [config.name]); let error; while (inputs.length > 0 && !error) { -@@ -1397,7 +1475,37 @@ async function runApplication({ root, defaultText, config }, rawInputs, context) +@@ -1397,7 +1445,37 @@ async function runApplication({ root, defaultText, config }, rawInputs, context) return ExitCode.UnknownCommand; } const result = scanner.finish(); @@ -601,7 +491,7 @@ index f76639637e812453d107bda3e3ec165fc195f4d8..b4b98087a21dd4566b100758fda1f2be const ansiColor = shouldUseAnsiColor(context.process, context.process.stdout, config.documentation); context.process.stdout.write( result.target.formatHelp({ -@@ -1466,6 +1574,9 @@ function withDefaults(config) { +@@ -1466,6 +1544,9 @@ function withDefaults(config) { const scannerConfig = { caseStyle: scannerCaseStyle, allowArgumentEscapeSequence: config.scanner?.allowArgumentEscapeSequence ?? false, @@ -611,7 +501,7 @@ index f76639637e812453d107bda3e3ec165fc195f4d8..b4b98087a21dd4566b100758fda1f2be distanceOptions: config.scanner?.distanceOptions ?? { threshold: 7, weights: { -@@ -1481,7 +1592,10 @@ function withDefaults(config) { +@@ -1481,7 +1562,10 @@ function withDefaults(config) { useAliasInUsageLine: config.documentation?.useAliasInUsageLine ?? false, onlyRequiredInUsageLine: config.documentation?.onlyRequiredInUsageLine ?? false, caseStyle: displayCaseStyle, @@ -623,7 +513,7 @@ index f76639637e812453d107bda3e3ec165fc195f4d8..b4b98087a21dd4566b100758fda1f2be }; const completionConfig = { includeAliases: config.completion?.includeAliases ?? documentationConfig.useAliasInUsageLine, -@@ -1833,7 +1947,7 @@ function formatDocumentationForFlagParameters(flags, aliases, args) { +@@ -1833,7 +1917,7 @@ function formatDocumentationForFlagParameters(flags, aliases, args) { if (args.includeHelpAllFlag) { const helpAllFlagName = formatForDisplay("helpAll", args.config.caseStyle); rows.push({ @@ -632,7 +522,7 @@ index f76639637e812453d107bda3e3ec165fc195f4d8..b4b98087a21dd4566b100758fda1f2be flagName: atLeastOneOptional ? ` --${helpAllFlagName}` : `--${helpAllFlagName}`, brief: briefs.helpAll, hidden: !args.config.alwaysShowHelpAllFlag -@@ -1872,7 +1986,7 @@ function* generateBuiltInFlagUsageLines(args) { +@@ -1872,7 +1956,7 @@ function* generateBuiltInFlagUsageLines(args) { yield args.config.useAliasInUsageLine ? "-h" : "--help"; if (args.includeHelpAllFlag) { const helpAllFlagName = formatForDisplay("helpAll", args.config.caseStyle); @@ -641,7 +531,7 @@ index f76639637e812453d107bda3e3ec165fc195f4d8..b4b98087a21dd4566b100758fda1f2be } if (args.includeVersionFlag) { yield args.config.useAliasInUsageLine ? "-v" : "--version"; -@@ -2009,7 +2123,7 @@ function checkForInvalidVariadicSeparators(flags) { +@@ -2009,7 +2093,7 @@ function checkForInvalidVariadicSeparators(flags) { function buildCommand(builderArgs) { const { flags = {}, aliases = {} } = builderArgs.parameters; checkForReservedFlags(flags, ["help", "helpAll", "help-all"]); diff --git a/packages/cli/script/check-patches.ts b/packages/cli/script/check-patches.ts index fe34f534c..c263c0ba0 100644 --- a/packages/cli/script/check-patches.ts +++ b/packages/cli/script/check-patches.ts @@ -221,13 +221,6 @@ for (const [key, patchPath] of Object.entries(patches)) { * it's guarded by a `requiredMarker` (`matchTopLevelFlag`) that must be present * once patched. Its absence means global flags before a subcommand will crash. * - * @stricli/core (array positional `combine`): an array positional may declare - * `combine`, which reads the parsed arguments as a whole and returns the one - * value the command gets, so a parse that depends on how many arguments were - * given runs in the argument scanner (see `boundArguments` in - * `src/lib/positional-parse.ts`). A pure insertion, guarded by the - * `combinePositionalValues` marker. - * * @stricli/core (`-v` version alias): the patch also drops Stricli's built-in * `-v`=version alias in `runApplication` so `-v` stays the Sentry CLI's * `--verbose` alias at every position; `--version` remains the version flag. @@ -306,18 +299,6 @@ const CONTENT_ASSERTIONS: ReadonlyArray<{ description: "@stricli/core CJS: pluggable `renderHelp` hook missing (`sentry --help --json` will fall back to text help instead of structured JSON)", }, - { - file: "@stricli/core/dist/index.js", - requiredMarker: "combinePositionalValues", - description: - "@stricli/core ESM: array positional `combine` hook missing (commands such as `log list` and `replay view` get no parsed arguments)", - }, - { - file: "@stricli/core/dist/index.cjs", - requiredMarker: "combinePositionalValues", - description: - "@stricli/core CJS: array positional `combine` hook missing (commands such as `log list` and `replay view` get no parsed arguments)", - }, ]; for (const assertion of CONTENT_ASSERTIONS) { diff --git a/packages/cli/src/app.ts b/packages/cli/src/app.ts index 7b87688ba..5359ae2de 100644 --- a/packages/cli/src/app.ts +++ b/packages/cli/src/app.ts @@ -1,5 +1,6 @@ import { type ApplicationText, + ArgumentScannerError, buildApplication, text_en, UnexpectedPositionalError, @@ -400,6 +401,12 @@ const customText: ApplicationText = { return base; }, exceptionWhileRunningCommand: (exc: unknown, ansiColor: boolean): string => { + // `boundArguments` positionals parse in the command wrapper, after + // Stricli's scanner; their errors still read as argument errors + if (exc instanceof ArgumentScannerError) { + return customText.exceptionWhileParsingArguments(exc, ansiColor); + } + // These errors are handled outside Stricli: OutputError has already been // rendered, while auth errors may trigger login and a single retry. if (escapesToOuterMiddleware(exc)) { diff --git a/packages/cli/src/lib/command.ts b/packages/cli/src/lib/command.ts index 4a8eee892..840300dcf 100644 --- a/packages/cli/src/lib/command.ts +++ b/packages/cli/src/lib/command.ts @@ -31,6 +31,7 @@ */ import { + ArgumentScannerError, type Command, type CommandContext, buildCommand as stricliCommand, @@ -71,7 +72,6 @@ import { checkDestructive, isHelpArgument, rawPositional, - throwDeferredParseErrors, } from "./positional-parse.js"; import { setArgsContext, setFlagContext, withTracing } from "./telemetry.js"; import { applyTipsPreference } from "./tips.js"; @@ -694,7 +694,10 @@ export function buildCommand< }, args: unknown[], ): Promise { - if (!(err instanceof CliError) || err instanceof OutputError) { + const recoverable = + (err instanceof CliError && !(err instanceof OutputError)) || + err instanceof ArgumentScannerError; + if (!recoverable) { return false; } if (!args.flatMap(rawPositional).some(isHelpArgument)) { @@ -729,12 +732,29 @@ export function buildCommand< ) { // Callers that skip Stricli's scanner (SDK invoke, tests) pass raw // strings, and omitted optional positionals are undefined; give bound - // positionals the value Stricli would. - const args = applyBoundPositionals( - positionalArgs, - existingParams.positional, - this, - ); + // positionals the value Stricli would. `boundArguments` positionals are + // parsed here for every caller. + let args: unknown[]; + let argumentError: ArgumentScannerError | undefined; + try { + args = applyBoundPositionals( + positionalArgs, + existingParams.positional, + this, + ); + } catch (error) { + // Next to a `help` argument, fail where it can turn into the help + if ( + !( + error instanceof ArgumentScannerError && + positionalArgs.some(isHelpArgument) + ) + ) { + throw error; + } + argumentError = error; + args = positionalArgs; + } applyLoggingFlags( flags[LOG_LEVEL_KEY] as LogLevelName | undefined, @@ -816,6 +836,10 @@ export function buildCommand< // `sentry issue list help`). Without this, the auth prompt would fire // before the help-recovery path could show the command's help text. try { + if (argumentError) { + throw argumentError; + } + // A usable stored account defers the check: the command's target may // select it even when the account in effect is logged out. if (requiresAuth && !getAuthConfig() && !hasUsableStoredAccount()) { @@ -829,10 +853,6 @@ export function buildCommand< await assertRcUrlTrusted(this.cwd as string); } - // An argument that failed to parse next to a `help` argument fails - // here, where it can turn into the command's help - throwDeferredParseErrors(args); - // Bound target positionals of destructive commands refuse to // auto-detect, whether `func` reads `parsed` or `resolved` if (builderArgs.destructive) { diff --git a/packages/cli/src/lib/errors.ts b/packages/cli/src/lib/errors.ts index 71fb08bd2..d20a84e1c 100644 --- a/packages/cli/src/lib/errors.ts +++ b/packages/cli/src/lib/errors.ts @@ -23,6 +23,7 @@ * @see https://cli.sentry.dev/exit-codes/ for full reference */ +import { ArgumentScannerError, ExitCode } from "@stricli/core"; import { redactCredentialText } from "./credential-redaction.js"; import { buildBillingUrl, @@ -845,12 +846,18 @@ export function formatError(error: unknown): string { * Get process exit code for an error. * * @param error - Any thrown value - * @returns Exit code (from CliError.exitCode or 1 for other errors) + * @returns Exit code (from CliError.exitCode, Stricli's `InvalidArgument` + * for an argument error the command wrapper threw, or 1 for other errors) */ export function getExitCode(error: unknown): number { if (error instanceof CliError) { return error.exitCode; } + // `boundArguments` positionals parse in the command wrapper, after + // Stricli's scanner; their errors exit like the scanner's + if (error instanceof ArgumentScannerError) { + return ExitCode.InvalidArgument; + } return 1; } diff --git a/packages/cli/src/lib/list-command.ts b/packages/cli/src/lib/list-command.ts index 7d1407a6c..19ac91ace 100644 --- a/packages/cli/src/lib/list-command.ts +++ b/packages/cli/src/lib/list-command.ts @@ -659,8 +659,9 @@ export function buildListCommand< mergedAliases.c = "cursor"; } - // `boundArguments` positionals parse every argument in the scanner, so a - // subcommand name is left out there, before it changes what the rest mean + // `boundArguments` positionals parse every argument before this wrapper + // runs, so a subcommand name is left out of that parse, before it changes + // what the other arguments mean const positional = skipLeadingArgument(params.positional, (arg) => isSubcommandName(arg, routeName), ); diff --git a/packages/cli/src/lib/positional-parse.ts b/packages/cli/src/lib/positional-parse.ts index 981343d4d..f9977e4ac 100644 --- a/packages/cli/src/lib/positional-parse.ts +++ b/packages/cli/src/lib/positional-parse.ts @@ -26,11 +26,13 @@ * the raw input with {@link rawPositional}. * * {@link boundArguments} does the same for positionals whose meaning depends - * on how many arguments were given, such as `[/] `: it - * parses all of them at once (in the scanner, through our Stricli patch's - * `combine` hook) and hands `func` one {@link BoundArguments}. + * on how many arguments were given, such as `[/] `. A + * Stricli parser sees one argument at a time, so `buildCommand` parses all + * of them at once before `func` runs and hands `func` one + * {@link BoundArguments}. */ +import { ArgumentParseError } from "@stricli/core"; import { type CompletionTarget, withCompletionTarget, @@ -42,15 +44,18 @@ const destructiveChecks = new WeakMap void>(); /** Parsers made by {@link boundPositional}. */ const boundParsers = new WeakSet(); -/** `combine` functions made by {@link boundArguments}. */ -const argumentCombiners = new WeakSet(); +/** + * Parses every argument of a {@link boundArguments} positional at once, into + * a {@link BoundArguments}. + */ +type ArgumentsParser = (this: unknown, raw: readonly string[]) => object; + +/** Whole-argument parsers of {@link boundArguments} positionals. */ +const argumentParsers = new WeakMap(); /** Leading arguments {@link skipLeadingArgument} left out, by bound value. */ const skippedArguments = new WeakMap(); -/** Parse errors of {@link boundArguments} values, thrown when `func` runs. */ -const deferredParseErrors = new WeakMap(); - /** * Whether the user typed `help` (or a mistyped help flag) as an argument, * which `buildCommand` answers with the command's help. @@ -127,11 +132,10 @@ export type BoundArguments = BoundPositional; * `[/] `, or a trailing trace ID). `func` gets one * {@link BoundArguments} instead of `...args: string[]`. * - * Each argument completes as `kind`. Stricli then hands every argument to - * `parse` through the positional's `combine` hook (our Stricli patch), so a - * parse error fails in the argument scanner like any other (exit 252). When - * an argument is `help`, the error is thrown when `func` would run instead, - * so `buildCommand` shows the command's help. + * Stricli only tags each argument for completion as `kind`. `buildCommand` + * then parses them all before `func` runs. A parse error becomes Stricli's + * {@link ArgumentParseError}, which fails like any other argument (exit 252), + * unless an argument is `help`: then it turns into the command's help. * * @param options.parse - Parses all the arguments, possibly none; may * throw. Must have no side effects @@ -155,40 +159,34 @@ export function boundArguments(options: { readonly brief: string; readonly parse: (input: string) => string; }; - readonly combine: ( - this: C, - values: readonly string[], - ) => BoundArguments; } { - const combine = function (this: C, values: readonly string[]) { + const positional = { + kind: "array", + parameter: { + placeholder: options.placeholder, + brief: options.brief, + parse: withCompletionTarget(options.kind, (input: string) => input), + }, + } as const; + argumentParsers.set(positional, function (this: unknown, values) { const raw = [...values]; let parsed: T; try { parsed = options.parse(raw); } catch (error) { - if (!raw.some(isHelpArgument)) { - throw error; - } - const failed = new BoundPositional(raw, undefined as T, () => - Promise.reject(error), + throw new ArgumentParseError( + options.placeholder as ConstructorParameters< + typeof ArgumentParseError + >[0], + raw.join(" "), + error, ); - deferredParseErrors.set(failed, error); - return failed; } return new BoundPositional(raw, parsed, (value: T) => - options.resolve(value, this, raw), + options.resolve(value, this as C, raw), ); - }; - argumentCombiners.add(combine); - return { - kind: "array", - parameter: { - placeholder: options.placeholder, - brief: options.brief, - parse: withCompletionTarget(options.kind, (input: string) => input), - }, - combine, - }; + }); + return positional; } /** @@ -201,28 +199,24 @@ export function skipLeadingArgument

( positional: P, skip: (arg: string) => boolean, ): P { - const def = positional as PositionalDef | undefined; - if ( - !( - def?.kind === "array" && - def.combine && - argumentCombiners.has(def.combine) - ) - ) { + const parseAll = + typeof positional === "object" && positional !== null + ? argumentParsers.get(positional as object) + : undefined; + if (!parseAll) { return positional; } - const combineAll = def.combine; - const combine = function (this: unknown, values: readonly string[]) { + const skipping = { ...(positional as object) } as P & object; + argumentParsers.set(skipping, function (this: unknown, values) { const [first, ...rest] = values; if (first === undefined || !skip(first)) { - return combineAll.call(this, values); + return parseAll.call(this, values); } - const bound = combineAll.call(this, rest) as object; + const bound = parseAll.call(this, rest); skippedArguments.set(bound, first); return bound; - }; - argumentCombiners.add(combine); - return { ...positional, combine }; + }); + return skipping; } /** The argument {@link skipLeadingArgument} left out of `value`, if any. */ @@ -235,11 +229,7 @@ export function skippedArgument(value: unknown): string | undefined { /** A Stricli positional definition — the subset that carries the parsers. */ type PositionalDef = | { kind: "tuple"; parameters: readonly { parse?: unknown }[] } - | { - kind: "array"; - parameter: { parse?: unknown }; - combine?: (this: unknown, values: readonly string[]) => unknown; - }; + | { kind: "array"; parameter: { parse?: unknown } }; /** The {@link boundPositional} parser at a positional index, if any. */ function boundParserAt( @@ -254,16 +244,19 @@ function boundParserAt( } /** - * Give every {@link boundPositional} positional its value. + * Give every {@link boundPositional} positional its value, and parse the + * arguments of a {@link boundArguments} positional into one + * {@link BoundArguments}. * - * Stricli already ran the parsers for typed input. This covers callers that - * skip its scanner (the SDK invoke path, tests calling the loaded func), - * which pass raw strings, and omitted optional positionals, which Stricli - * leaves `undefined`. Other positionals are left as they are. Raw strings - * for a {@link boundArguments} positional become one {@link BoundArguments}. + * Stricli already ran the {@link boundPositional} parsers for typed input. + * This covers callers that skip its scanner (the SDK invoke path, tests + * calling the loaded func), which pass raw strings, and omitted optional + * positionals, which Stricli leaves `undefined`. Other positionals are left + * as they are. * * @param context - Passed to each parser as `this`, as Stricli does - * @throws The parser's error for an invalid raw string + * @throws The parser's error for an invalid raw string, or an + * {@link ArgumentParseError} for {@link boundArguments} */ export function applyBoundPositionals( args: readonly unknown[], @@ -274,16 +267,13 @@ export function applyBoundPositionals( if (!def) { return [...args]; } - if ( - def.kind === "array" && - def.combine && - argumentCombiners.has(def.combine) - ) { - // Stricli's scanner already combined them into one bound value + const parseAll = argumentParsers.get(def); + if (parseAll) { + // Already parsed when a wrapper applies them again if (args.length === 1 && args[0] instanceof BoundPositional) { return [...args]; } - return [def.combine.call(context, args as readonly string[])]; + return [parseAll.call(context, args as readonly string[])]; } const count = def.kind === "tuple" @@ -313,18 +303,6 @@ export function checkDestructive(args: readonly unknown[]): void { } } -/** - * Throw the parse error {@link boundArguments} deferred because an argument - * was `help`. Other values are ignored. - */ -export function throwDeferredParseErrors(args: readonly unknown[]): void { - for (const arg of args) { - if (arg instanceof BoundPositional && deferredParseErrors.has(arg)) { - throw deferredParseErrors.get(arg); - } - } -} - /** * What the user typed for a positional value. * diff --git a/packages/cli/test/commands/replay/download.test.ts b/packages/cli/test/commands/replay/download.test.ts index 3b2a672c6..0763e2553 100644 --- a/packages/cli/test/commands/replay/download.test.ts +++ b/packages/cli/test/commands/replay/download.test.ts @@ -8,6 +8,7 @@ import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { ArgumentParseError } from "@stricli/core"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { downloadCommand } from "../../../src/commands/replay/download.js"; @@ -265,7 +266,12 @@ describe("replay download", () => { .call(createContext().context, { fresh: false }) .catch((caught: unknown) => caught); - expect(error).toBeInstanceOf(ContextError); - expect((error as ContextError).message).toContain("sentry replay download"); + // An argument error, as the CLI reports it, wrapping the parser's error + expect(error).toBeInstanceOf(ArgumentParseError); + const { exception } = error as ArgumentParseError; + expect(exception).toBeInstanceOf(ContextError); + expect((exception as ContextError).message).toContain( + "sentry replay download", + ); }); }); diff --git a/packages/cli/test/commands/replay/view.test.ts b/packages/cli/test/commands/replay/view.test.ts index 8fd667824..0e2c206f8 100644 --- a/packages/cli/test/commands/replay/view.test.ts +++ b/packages/cli/test/commands/replay/view.test.ts @@ -2,9 +2,11 @@ * Replay View Command Tests */ +import type { ArgumentParseError } from "@stricli/core"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { parsePositionalArgs, + type ReplayArguments, replayArguments, viewCommand, } from "../../../src/commands/replay/view.js"; @@ -55,6 +57,7 @@ vi.mock("../../../src/lib/resolve-target.js", async (importOriginal) => { ); }); +import { applyBoundPositionals } from "../../../src/lib/positional-parse.js"; // oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for spyOn mocking import * as resolveTarget from "../../../src/lib/resolve-target.js"; import type { ReplayDetails } from "../../../src/types/index.js"; @@ -414,12 +417,11 @@ describe("viewCommand.func", () => { describe("replayArguments", () => { const positional = replayArguments("view", "sentry replay view "); const context = { cwd: "/tmp" }; + const parse = (raw: string[]) => + applyBoundPositionals(raw, positional, context)[0] as ReplayArguments; test("parses the replay ID and target without resolving", () => { - const target = positional.combine.call(context, [ - "test-org/cli", - REPLAY_ID, - ]); + const target = parse(["test-org/cli", REPLAY_ID]); expect(target.parsed).toEqual({ replayId: REPLAY_ID, target: { type: "explicit", org: "test-org", project: "cli" }, @@ -429,13 +431,19 @@ describe("replayArguments", () => { }); test("rejects an invalid replay ID when parsing", () => { - expect(() => positional.combine.call(context, ["not-hex"])).toThrow( + let error: unknown; + try { + parse(["not-hex"]); + } catch (caught) { + error = caught; + } + expect((error as ArgumentParseError).exception).toBeInstanceOf( ValidationError, ); }); test("applies a pasted URL's server when resolving, not when parsing", async () => { - const target = positional.combine.call(context, [ + const target = parse([ `https://sentry.example.com/organizations/acme/explore/replays/${REPLAY_ID}/`, ]); expect(target.parsed.baseUrl).toBe("https://sentry.example.com"); diff --git a/packages/cli/test/lib/help-positional.test.ts b/packages/cli/test/lib/help-positional.test.ts index 43ff47584..40429805f 100644 --- a/packages/cli/test/lib/help-positional.test.ts +++ b/packages/cli/test/lib/help-positional.test.ts @@ -187,6 +187,34 @@ describe("help recovery on ValidationError", () => { }); }); +describe("help recovery on argument parse errors", () => { + test("sentry replay view help → shows help instead of the parse error", async () => { + // `replay view`'s arguments parse together in the command wrapper; "help" + // fails as a replay ID there, where recovery can still catch it + const { stdout, stderr } = await runCommand(["replay", "view", "help"]); + + expect(stdout).toContain("sentry replay view"); + expect(stderr).toContain("sentry replay view --help"); + expect(stderr).not.toContain("Failed to parse"); + }); + + test("sentry replay view not-hex → argument parse error", async () => { + // Stricli writes argument errors to the real process's stderr + const stderrWrite = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + try { + const { stdout } = await runCommand(["replay", "view", "not-hex"]); + expect(stdout).not.toContain("sentry replay view"); + expect(stderrWrite.mock.calls.map(([s]) => String(s)).join("")).toContain( + 'Failed to parse "not-hex" for replay-id-or-url', + ); + } finally { + stderrWrite.mockRestore(); + } + }); +}); + describe("help command unchanged", () => { test("sentry help still shows branded help", async () => { const { stdout, stderr } = await runCommand(["help"]); diff --git a/packages/cli/test/lib/positional-parse.test.ts b/packages/cli/test/lib/positional-parse.test.ts index 547217a31..099fe70af 100644 --- a/packages/cli/test/lib/positional-parse.test.ts +++ b/packages/cli/test/lib/positional-parse.test.ts @@ -7,6 +7,7 @@ */ import { + ArgumentParseError, buildApplication, type CommandContext, ExitCode, @@ -35,7 +36,6 @@ import { rawPositional, skipLeadingArgument, skippedArgument, - throwDeferredParseErrors, } from "../../src/lib/positional-parse.js"; import { buildRouteMap } from "../../src/lib/route-map.js"; import { @@ -360,22 +360,6 @@ describe("boundArguments", () => { expect(stderr).toContain("Too many arguments"); }); - test("a parse error next to `help` fails when func would run instead", async () => { - idResolver.mockClear(); - const { resolved, stderr, exitCode } = await runWithArguments([ - "help", - "a", - "b", - ]); - expect(resolved).toEqual([]); - expect(idResolver).not.toHaveBeenCalled(); - // Thrown by the command wrapper, where `help` recovery can catch it, - // rather than by Stricli's scanner - expect(stderr).not.toContain("Failed to parse"); - expect(stderr).toContain("Too many arguments"); - expect(exitCode).not.toBe(ExitCode.InvalidArgument); - }); - test("parses no arguments too", async () => { const { resolved, exitCode, stderr } = await runWithArguments([]); expect(resolved).toEqual([]); @@ -399,14 +383,21 @@ describe("boundArguments", () => { expect(idResolver).not.toHaveBeenCalled(); await expect(bound.resolved).resolves.toMatchObject({ cwd: "/work" }); - // A value Stricli already combined is kept + // A value already parsed is kept expect(applyBoundPositionals([bound], idArguments, context)).toEqual([ bound, ]); }); - test("raw strings that fail to parse throw the parser's error", () => { - expect(() => applyBoundPositionals([], idArguments, {})).toThrow( + test("raw strings that fail to parse throw an argument parse error", () => { + let error: unknown; + try { + applyBoundPositionals([], idArguments, {}); + } catch (caught) { + error = caught; + } + expect(error).toBeInstanceOf(ArgumentParseError); + expect((error as ArgumentParseError).exception).toBeInstanceOf( ContextError, ); }); @@ -418,7 +409,7 @@ describe("boundArguments", () => { test("skipLeadingArgument leaves a matching first argument out before parsing", async () => { const skipping = skipLeadingArgument(idArguments, (arg) => arg === "view"); - // Parsed in the scanner without "view", so the count still decides + // Parsed without "view", so the count still decides expect( (await runWithArguments(["view", "acme/web", "abc"], skipping)).resolved, ).toEqual([ @@ -432,16 +423,6 @@ describe("boundArguments", () => { expect(skippedArgument(kept)).toBeUndefined(); }); - test("skipLeadingArgument keeps a parse error next to `help` deferred", () => { - const skipping = skipLeadingArgument(idArguments, (arg) => arg === "view"); - const args = applyBoundPositionals( - ["view", "help", "a", "b"], - skipping, - {}, - ); - expect(() => throwDeferredParseErrors(args)).toThrow("Too many arguments"); - }); - test("each argument completes as the given kind", () => { expect(getCompletionTarget(idArguments.parameter.parse)).toBe( ORG_PROJECT_TARGET, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da2b10417..303983414 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -203,7 +203,7 @@ packageExtensionsChecksum: sha256-PIoL2jn0sFpFHgcMKW97dNeqBXVNxH6qFIzsWIBFOMY= patchedDependencies: '@ai-sdk/provider-utils@2.2.8': 4649a74595006c4a262d453549e091db331ade06b4f4320957af1b2c659b8ee3 '@cloudflare/workers-oauth-provider@0.10.3': 34910b67929c1888b1492384c514d6baeedc4a439f66e21375dee5af404834f8 - '@stricli/core@1.2.8': a9904d4240892b55b3a1973098ead2c5df8af57cd65290000b095ee4a14f4794 + '@stricli/core@1.2.8': cbf047581d38be04108d3921321c14a778272c24004da8110d4498d4f2a6b2e1 importers: @@ -497,7 +497,7 @@ importers: version: 1.3.0 '@stricli/core': specifier: 1.2.8 - version: 1.2.8(patch_hash=a9904d4240892b55b3a1973098ead2c5df8af57cd65290000b095ee4a14f4794) + version: 1.2.8(patch_hash=cbf047581d38be04108d3921321c14a778272c24004da8110d4498d4f2a6b2e1) '@types/http-cache-semantics': specifier: ^4.2.0 version: 4.2.0 @@ -13149,7 +13149,7 @@ snapshots: dependencies: '@stricli/core': 1.3.0 - '@stricli/core@1.2.8(patch_hash=a9904d4240892b55b3a1973098ead2c5df8af57cd65290000b095ee4a14f4794)': {} + '@stricli/core@1.2.8(patch_hash=cbf047581d38be04108d3921321c14a778272c24004da8110d4498d4f2a6b2e1)': {} '@stricli/core@1.3.0': {} @@ -14902,7 +14902,7 @@ snapshots: fossilize@0.10.1: dependencies: '@stricli/auto-complete': 1.3.0 - '@stricli/core': 1.2.8(patch_hash=a9904d4240892b55b3a1973098ead2c5df8af57cd65290000b095ee4a14f4794) + '@stricli/core': 1.2.8(patch_hash=cbf047581d38be04108d3921321c14a778272c24004da8110d4498d4f2a6b2e1) binpunch: 1.0.0 esbuild: 0.25.12 macho-unsign: 2.0.6 From c035d23c31c4e185ee2a6efeb2320965c70f70bd Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:57:32 +0000 Subject: [PATCH 04/13] ref(cli): Parse ID arguments with a Peggy grammar The TypeScript that decided which argument is the ID and which is its target (`replay view`'s `parsePositionalArgs`, `parseTraceTargetInput`, `parseSlashSeparatedTraceTarget`, `parseDualModeInput`) counted arguments and split on slashes by hand. `grammar/id-arguments.pegjs` now does that, over every argument at once: each one, trimmed, follows a NUL, so arity is part of the input. Its start rules are `trace`, `tracePath`, `traceOrProject` (log list, span list) and `replay`. They return what the arguments name (`IdArguments`: the ID, its org/project scope, a URL's server, and warnings), and callers map that onto their own target types. ID checks and errors go through hooks, so messages and error classes match what they were. `generate-parsers.mjs` compiles the `startRule` literals of a grammar's declarations as its allowed start rules. Behavior changes, all for input that used to fail or misparse: - `log list`/`span list`: trace mode and the trace ID now come from the same argument, so ` ` warns about `` instead of rejecting it as the trace ID. - `replay`: a slash after the replay ID and a leading `/` are accepted, like for trace IDs. Co-Authored-By: BYK --- packages/cli/AGENTS.md | 8 + packages/cli/grammar/id-arguments.d.ts | 48 ++++ packages/cli/grammar/id-arguments.pegjs | 178 +++++++++++++ packages/cli/src/commands/replay/view.ts | 161 +++--------- packages/cli/src/lib/id-arguments.ts | 126 +++++++++ packages/cli/src/lib/trace-target.ts | 244 +++++------------- .../cli/test/commands/replay/view.test.ts | 81 ++++-- packages/cli/test/lib/id-arguments.test.ts | 159 ++++++++++++ packages/cli/test/lib/trace-target.test.ts | 8 +- scripts/generate-parsers.mjs | 37 ++- 10 files changed, 722 insertions(+), 328 deletions(-) create mode 100644 packages/cli/grammar/id-arguments.d.ts create mode 100644 packages/cli/grammar/id-arguments.pegjs create mode 100644 packages/cli/src/lib/id-arguments.ts create mode 100644 packages/cli/test/lib/id-arguments.test.ts diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index bb39eece1..44153c365 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -286,6 +286,14 @@ command's help instead. The same purity rule applies: return a pasted URL's serv (`parseTraceTargetInput` and `parseDualModeInput` in `trace-target.ts` do this for trace targets). +Which argument is the ID and which is its target is decided by +`grammar/id-arguments.pegjs` (through `parseIdArguments` in +`src/lib/id-arguments.ts`), not by counting arguments in TypeScript. Add a +start rule there for a new `[/[/]]` shape and list it in +the `startRule` types of `grammar/id-arguments.d.ts`; the generator compiles +every rule listed there as a start rule. Errors go through the grammar's +hooks, never Peggy's own syntax errors. + **Shell completion:** Target positionals that take an org, project, issue, or dashboard use a completion-aware parser from `src/lib/completion-target.ts` instead of `String`: `parseOrgProjectTarget` completes `org/`, `org/project`, diff --git a/packages/cli/grammar/id-arguments.d.ts b/packages/cli/grammar/id-arguments.d.ts new file mode 100644 index 000000000..74dd7e572 --- /dev/null +++ b/packages/cli/grammar/id-arguments.d.ts @@ -0,0 +1,48 @@ +// Types of the parser generated from id-arguments.pegjs. The generator +// copies this file to src/generated/id-arguments-grammar.d.ts and rewrites +// relative imports to resolve from there. It also compiles every `startRule` +// listed here as a start rule. + +import type { + IdArguments, + TraceOrProjectArguments, +} from "../src/lib/id-arguments.js"; +import type { ParsedSentryUrl } from "../src/lib/sentry-url-parser.js"; + +/** + * Hooks the ID-arguments grammar calls to check IDs and to throw, so its + * errors match the rest of the CLI. See `parseIdArguments()`. + */ +export type IdArgumentsGrammarOptions = { + /** Usage shown when arguments are ignored or there are too many. */ + usageHint: string; + /** Whether `value` is an ID. */ + isId: (value: string) => boolean; + /** Normalize an ID; throw when `value` is not one. */ + id: (value: string) => string; + /** Parse a Sentry web URL, or return `null` for anything else. */ + sentryUrl: (value: string) => ParsedSentryUrl | null; + /** Throw for a missing ID. */ + missing: (suggestions?: string[]) => never; + /** Throw a validation error. */ + invalid: (message: string, field?: string) => never; +}; + +export declare class SyntaxError extends Error { + expected: unknown; + found: string | null; + location: unknown; +} + +/** Parse NUL-prefixed arguments that end in an ID. */ +export declare function parse( + input: string, + options: IdArgumentsGrammarOptions & { + startRule: "trace" | "tracePath" | "replay"; + }, +): IdArguments; +/** Parse NUL-prefixed arguments that list a project's or a trace's items. */ +export declare function parse( + input: string, + options: IdArgumentsGrammarOptions & { startRule: "traceOrProject" }, +): TraceOrProjectArguments; diff --git a/packages/cli/grammar/id-arguments.pegjs b/packages/cli/grammar/id-arguments.pegjs new file mode 100644 index 000000000..7577da943 --- /dev/null +++ b/packages/cli/grammar/id-arguments.pegjs @@ -0,0 +1,178 @@ +// Grammar for positionals that end in an ID, where what an argument means +// depends on how many were given: `[/[/]]` in one +// argument, ` ` in two, or a Sentry URL. +// `pnpm run generate:grammar` compiles it, with the types in +// id-arguments.d.ts, into the gitignored src/generated/; the build, +// typecheck, and test scripts run it. +// +// `parseIdArguments()` (src/lib/id-arguments.ts) parses every argument at +// once: each one is trimmed and follows a NUL, which no command-line argument +// contains. So no arguments is the empty input, and `[""]` is "\0". The +// grammar decides which argument is the ID and which is its target. ID checks +// and errors go through the `isId`, `id`, `sentryUrl`, `missing`, and +// `invalid` hooks in `options`, so they match the rest of the CLI. The result +// names the parts of the target (`IdArguments`); the caller turns them into +// a trace or org/project target. +// +// Start rules: +// - `trace`: trace view, trace logs, and span list in trace mode +// - `tracePath`: one argument and no URL (the trace part of span view's) +// - `traceOrProject`: log list and span list, which list a project's items +// or a trace's +// - `replay`: replay view and replay download + +{ + const { usageHint, isId, id, sentryUrl, missing, invalid } = options; + + const NO_SCOPE = { type: "none" }; + + // The parse is the same every time; a predicate and the action after it + // can both ask + const urls = new Map(); + function url(value) { + if (!urls.has(value)) { + urls.set(value, sentryUrl(value)); + } + return urls.get(value); + } + + function ignored(extra) { + return extra.length > 0 + ? [`Extra arguments ignored: ${extra.join(" ")}. Expected: ${usageHint}`] + : []; + } + + // `[/[/]]`, as split by `idPath`. Without the ID, or with + // only one of `/`, the ID is missing. + function fromPath(path, warnings = []) { + if (path.id === "" && (path.root || path.org !== undefined)) { + missing(); + } + const value = id(path.id); + if (path.org === undefined) { + return { id: value, scope: NO_SCOPE, warnings }; + } + if (path.project === undefined) { + return { id: value, scope: { type: "org", org: path.org }, warnings }; + } + if (path.org === "" || path.project === "") { + missing(); + } + const { org, project } = path; + return { id: value, scope: { type: "org-project", org, project }, warnings }; + } + + // ` `; the caller parses the target + function fromTarget(target, value, warnings = []) { + return { id: id(value), scope: { type: "target", target }, warnings }; + } + + // A Sentry URL with the ID in it + function fromUrl(parsed, value) { + return { + id: id(value), + scope: parsed.org ? { type: "org", org: parsed.org } : NO_SCOPE, + baseUrl: parsed.baseUrl, + warnings: [], + }; + } + + function traceMode(trace) { + return { mode: "trace", trace }; + } +} + +// Peggy runs a rule's action as soon as that rule matches, even when the +// enclosing alternative fails later and the parser backtracks. The rules +// below the start rules therefore only capture text, and the hooks that +// throw run in the action of the start rule alternative that wins. + +// `[/[/]]`, ` `, or a trace URL. +// Arguments after those are ignored with a warning, or silently after a URL +// (which may be followed by what the browser's address bar had around it). +trace + = u:traceUrl arg* { return fromUrl(u, u.traceId); } + / sep p:idPath end { return fromPath(p); } + / t:arg i:arg x:arg* { return fromTarget(t, i, ignored(x)); } + / end { missing(); } + +// `[/[/]]` in one argument +tracePath + = sep p:idPath end { return fromPath(p); } + +// A trace's items when the arguments end in a trace ID or are a trace URL, +// otherwise a project's (`target` is the first argument, if any). +traceOrProject + = end { return { mode: "project" }; } + / u:traceUrl arg* { return traceMode(fromUrl(u, u.traceId)); } + // ` ` + / t:arg x:(!lastArg @arg)* i:lastArg &{ return isId(i); } + { return traceMode(fromTarget(t, i, ignored(x))); } + // `[/[/]]` + / sep p:idPath x:arg* &{ return isId(p.id); } + { return traceMode(fromPath(p, ignored(x))); } + / t:arg arg* { return { mode: "project", target: t }; } + +// `[/[/]]`, ` `, or a replay URL +replay + = end { missing(); } + / a:arg|3..| { + invalid( + `Too many positional arguments (got ${a.length}, expected at most 2).\n\nUsage: ${usageHint}`, + "positional", + ); + } + / sep argEnd arg? end { missing(); } + / u:arg &{ return url(u) !== null; } arg? end { + const parsed = url(u); + if (!(parsed.replayId && parsed.org)) { + missing([ + "Pass a replay URL: https://sentry.io/organizations/{org}/explore/replays/{replayId}/", + ]); + } + return fromUrl(parsed, parsed.replayId); + } + // `/` without the replay ID + / sep p:idPath end + &{ return p.org !== undefined && p.project === undefined && !isId(p.id); } + { missing(); } + / sep p:idPath end { return fromPath(p); } + / arg sep end { missing(); } + // ` /`: the arguments are reversed + / sep i:$[^/\0]+ sep t:$([^/\0]* "/" [^\0]*) end { + return fromTarget(t, i, [ + `Arguments appear reversed. Interpreting as: ${t} ${i}`, + ]); + } + / t:arg i:arg end { return fromTarget(t, i); } + +// A Sentry URL with a trace ID in it +traceUrl + = u:arg &{ return Boolean(url(u)?.traceId); } { return url(u); } + +// `[/[/]]` in one argument. The ID is the last segment, +// even when slashes follow it (pasted from a URL); otherwise a trailing slash +// leaves the ID out. `` is everything before the first slash and +// `` the rest. +idPath + = i:idSegment argEnd { return { id: i }; } + / "/" i:idSegment argEnd { return { id: i, root: true }; } + / o:$[^/\0]+ "/" i:idSegment argEnd { return { id: i, org: o }; } + / o:$[^/\0]* "/" p:$(!("/" idSegment argEnd) [^\0])* "/" i:idSegment argEnd + { return { id: i, org: o, project: p }; } + +// A segment, and the slashes after it when it is an ID +idSegment + = i:$[^/\0]* s:$"/"* &{ return s === "" || isId(i); } { return i; } + +// One argument +arg = sep @$[^\0]* + +// The last argument +lastArg = @arg end + +sep = "\0" + +argEnd = &sep / end + +end = !. diff --git a/packages/cli/src/commands/replay/view.ts b/packages/cli/src/commands/replay/view.ts index 044d75cbe..ab3fb1d8d 100644 --- a/packages/cli/src/commands/replay/view.ts +++ b/packages/cli/src/commands/replay/view.ts @@ -13,17 +13,14 @@ import { resolveReplay, } from "../../lib/api-client.js"; import { - detectSwappedViewArgs, type ParsedOrgProject, parseOrgProjectInput, - parseSlashSeparatedArg, } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; import { ORG_PROJECT_TARGET } from "../../lib/completion-target.js"; import { ApiError, - ContextError, ResolutionError, ValidationError, } from "../../lib/errors.js"; @@ -35,7 +32,7 @@ import { type ReplayViewData, replayHint, } from "../../lib/formatters/replay.js"; -import { tryNormalizeHexId, validateHexId } from "../../lib/hex-id.js"; +import { type IdScope, parseIdArguments } from "../../lib/id-arguments.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -50,10 +47,7 @@ import { type ResolvedOrgOptionalTarget, resolveOrgOptionalTarget, } from "../../lib/resolve-target.js"; -import { - applySentryUrlContext, - parseSentryUrl, -} from "../../lib/sentry-url-parser.js"; +import { applySentryUrlContext } from "../../lib/sentry-url-parser.js"; import { buildReplayUrl } from "../../lib/sentry-web-urls.js"; import type { ReplayActivityEvent, @@ -70,14 +64,6 @@ type ViewFlags = { readonly fields?: string[]; }; -type ParsedPositionalArgs = { - replayId: string; - targetArg: string | undefined; - warning?: string; - /** Server of a pasted replay URL, for `applySentryUrlContext` */ - baseUrl?: string; -}; - const USAGE_HINT = "sentry replay view [//] | "; const MAX_ACTIVITY_EVENTS = 6; @@ -87,110 +73,50 @@ const MAX_RELATED_TRACES = 2; const log = logger.withTag("replay.view"); /** - * Parse a single positional argument as a replay target. - * - * Handles bare replay IDs, `/`, `//`, - * and Sentry replay URLs. The single-slash case (`org/id`) needs special - * handling because 32-char hex replay IDs look valid to the generic - * `parseSlashSeparatedArg` which would misinterpret the org as a project. - */ -function parseSingleArg(arg: string, usageHint: string): ParsedPositionalArgs { - const trimmed = arg.trim(); - if (!trimmed) { - throw new ContextError("Replay ID", usageHint, []); - } - - // Handle / shorthand — must check before parseSlashSeparatedArg - // because replay IDs are 32-char hex strings that look valid to the generic - // slash parser's ID extraction, but with only one slash the "project" segment - // would be wrongly treated as the ID. - const slashIdx = trimmed.indexOf("/"); - if (slashIdx !== -1 && trimmed.indexOf("/", slashIdx + 1) === -1) { - const org = trimmed.slice(0, slashIdx); - const replaySegment = trimmed.slice(slashIdx + 1); - const normalizedReplayId = - replaySegment && tryNormalizeHexId(replaySegment); - if (!normalizedReplayId) { - throw new ContextError("Replay ID", usageHint, []); - } - return { replayId: normalizedReplayId, targetArg: `${org}/` }; - } - - const { id: replayId, targetArg } = parseSlashSeparatedArg( - trimmed, - "Replay ID", - usageHint, - ); - return { replayId, targetArg }; -} - -/** - * Parse replay view positional arguments. - * - * Supports: + * Parse `replay view` and `replay download` arguments with the `replay` rule + * of `grammar/id-arguments.pegjs`: * - `` * - `/` * - `//` - * - ` ` + * - ` ` (or reversed, with a warning) * - `` * * `usageHint` lets sibling replay commands show their own usage in errors. * Has no side effects: a pasted URL's server is returned as `baseUrl`. */ -export function parsePositionalArgs( +export function parseReplayArguments( args: readonly string[], usageHint = USAGE_HINT, -): ParsedPositionalArgs { - if (args.length === 0) { - throw new ContextError("Replay ID", usageHint, []); - } - if (args.length > 2) { - throw new ValidationError( - `Too many positional arguments (got ${args.length}, expected at most 2).\n\nUsage: ${usageHint}`, - "positional", - ); - } - - const first = args[0]; - if (!first) { - throw new ContextError("Replay ID", usageHint, []); - } +): ReplayArgumentsInput { + const { id, scope, baseUrl, warnings } = parseIdArguments(args, "replay", { + idLabel: "Replay ID", + usageHint, + }); + const target = parseOrgProjectInput(replayTargetArg(scope)); + return { + replayId: id, + target: target.parsed, + baseUrl: baseUrl ?? target.baseUrl, + warnings, + }; +} - const urlParsed = parseSentryUrl(first); - if (urlParsed) { - if (urlParsed.replayId && urlParsed.org) { - return { - replayId: urlParsed.replayId, - targetArg: `${urlParsed.org}/`, - baseUrl: urlParsed.baseUrl, - }; +/** The org/project target argument a replay's {@link IdScope} stands for. */ +function replayTargetArg(scope: IdScope): string | undefined { + switch (scope.type) { + case "none": + return; + case "org": + return `${scope.org}/`; + case "org-project": + return `${scope.org}/${scope.project}`; + case "target": + return scope.target; + default: { + const _exhaustive: never = scope; + throw new ValidationError(`Unexpected replay scope: ${_exhaustive}`); } - throw new ContextError("Replay ID", usageHint, [ - "Pass a replay URL: https://sentry.io/organizations/{org}/explore/replays/{replayId}/", - ]); - } - - if (args.length === 1) { - return parseSingleArg(first, usageHint); } - - const second = args[1]; - if (!second) { - throw new ContextError("Replay ID", usageHint, []); - } - - const warning = - args.length === 2 ? detectSwappedViewArgs(first, second) : null; - if (warning) { - const normalizedReplayId = tryNormalizeHexId(first) ?? first; - return { - replayId: normalizedReplayId, - targetArg: second, - warning, - }; - } - - return { replayId: second, targetArg: first }; } /** Parsed `replay view` and `replay download` arguments. */ @@ -199,7 +125,8 @@ export type ReplayArgumentsInput = { readonly target: ParsedOrgProject; /** Server of a pasted Sentry URL, for `applySentryUrlContext` */ readonly baseUrl?: string; - readonly warning?: string; + /** Warnings to log, such as for reversed arguments */ + readonly warnings: readonly string[]; }; /** A replay ID and the org (and optional project) to look it up in. */ @@ -226,19 +153,7 @@ export function replayArguments( placeholder: "replay-id-or-url", brief: "[/] or ", kind: ORG_PROJECT_TARGET, - parse: (raw): ReplayArgumentsInput => { - const { replayId, targetArg, warning, baseUrl } = parsePositionalArgs( - raw, - usageHint, - ); - const target = parseOrgProjectInput(targetArg); - return { - replayId: validateHexId(replayId, "replay ID"), - target: target.parsed, - baseUrl: baseUrl ?? target.baseUrl, - warning, - }; - }, + parse: (raw) => parseReplayArguments(raw, usageHint), resolve: async ( input, context: SentryContext, @@ -246,8 +161,8 @@ export function replayArguments( if (input.baseUrl) { applySentryUrlContext(input.baseUrl); } - if (input.warning) { - commandLog.warn(input.warning); + for (const warning of input.warnings) { + commandLog.warn(warning); } const resolved = await resolveOrgOptionalTarget( input.target, diff --git a/packages/cli/src/lib/id-arguments.ts b/packages/cli/src/lib/id-arguments.ts new file mode 100644 index 000000000..e5ef5b0aa --- /dev/null +++ b/packages/cli/src/lib/id-arguments.ts @@ -0,0 +1,126 @@ +/** + * Positionals that end in an ID, parsed by `grammar/id-arguments.pegjs`. + * + * What these arguments mean depends on how many were given: + * `[/[/]]` in one argument, ` ` in two, or a + * Sentry URL. The grammar sees every argument at once, decides which one is + * the ID and which is the target, and names the target's parts. Callers turn + * the result into their own target type (see `trace-target.ts` and + * `commands/replay/view.ts`). + * + * Parsing has no side effects: a pasted URL's server comes back as `baseUrl` + * and ignored arguments as `warnings`, for the caller to apply. + */ + +import { parse } from "../generated/id-arguments-grammar.js"; +import { ContextError, ValidationError } from "./errors.js"; +import { tryNormalizeHexId, validateHexId } from "./hex-id.js"; +import { parseSentryUrl } from "./sentry-url-parser.js"; + +/** What the arguments said to look the ID up in. */ +export type IdScope = + /** Nothing: `` or a URL without an org */ + | { readonly type: "none" } + /** `/` or a URL's org */ + | { readonly type: "org"; readonly org: string } + /** `//` */ + | { + readonly type: "org-project"; + readonly org: string; + readonly project: string; + } + /** ` `: a separate target argument, not parsed yet */ + | { readonly type: "target"; readonly target: string }; + +/** Arguments that end in an ID, parsed. */ +export type IdArguments = { + /** The normalized ID */ + readonly id: string; + readonly scope: IdScope; + /** Server of a pasted Sentry URL, for `applySentryUrlContext` */ + readonly baseUrl?: string; + /** Warnings to log, such as for ignored arguments */ + readonly warnings: readonly string[]; +}; + +/** Arguments that list a project's items or, ending in a trace ID, a trace's. */ +export type TraceOrProjectArguments = + /** `target` is the first argument, if any */ + | { readonly mode: "project"; readonly target?: string } + | { readonly mode: "trace"; readonly trace: IdArguments }; + +/** The grammar's start rules that parse {@link IdArguments}. */ +type IdArgumentsRule = "trace" | "tracePath" | "replay"; + +/** What an ID is called, and how the command is used, for errors. */ +type IdArgumentsContext = { + /** e.g. `"Trace ID"` */ + readonly idLabel: string; + readonly usageHint: string; +}; + +/** + * Parse arguments that end in an ID. + * + * @param rule - `trace` or `replay` for the arguments of those commands, + * `tracePath` for one `[/[/]]` argument + * @throws {ContextError} When the ID is missing + * @throws {ValidationError} When the ID is invalid or there are too many + * arguments + */ +export function parseIdArguments( + args: readonly string[], + rule: IdArgumentsRule, + context: IdArgumentsContext, +): IdArguments { + return parse(grammarInput(args), { ...hooks(context), startRule: rule }); +} + +/** + * Parse the arguments of a command that lists a project's items or a + * trace's: trace mode when they are a trace URL, end in a trace ID, or start + * with `[/[/]]`. + */ +export function parseTraceOrProjectArguments( + args: readonly string[], + usageHint: string, +): TraceOrProjectArguments { + return parse(grammarInput(args), { + ...hooks({ idLabel: "Trace ID", usageHint }), + startRule: "traceOrProject", + }); +} + +/** + * The grammar's input: every argument, trimmed, after a NUL. A command line + * cannot contain NUL, so the grammar can always tell the arguments apart. + */ +function grammarInput(args: readonly string[]): string { + return args + .map((arg) => { + if (arg.includes("\0")) { + throw new ValidationError( + "Arguments cannot contain NUL characters.", + "positional", + ); + } + return `\0${arg.trim()}`; + }) + .join(""); +} + +function hooks({ idLabel, usageHint }: IdArgumentsContext) { + const label = idLabel.charAt(0).toLowerCase() + idLabel.slice(1); + return { + usageHint, + isId: (value: string) => tryNormalizeHexId(value) !== undefined, + id: (value: string) => validateHexId(value, label), + sentryUrl: parseSentryUrl, + missing: (suggestions: string[] = []): never => { + throw new ContextError(idLabel, usageHint, suggestions); + }, + invalid: (message: string, field?: string): never => { + throw new ValidationError(message, field); + }, + }; +} diff --git a/packages/cli/src/lib/trace-target.ts b/packages/cli/src/lib/trace-target.ts index 7cfc4b251..6cdb82798 100644 --- a/packages/cli/src/lib/trace-target.ts +++ b/packages/cli/src/lib/trace-target.ts @@ -17,7 +17,6 @@ */ import { - normalizeSlug, type ParsedOrgProject, parseOrgProjectArg, parseOrgProjectInput, @@ -28,6 +27,11 @@ import { recoverHexId, resolveRecoveryOrg, } from "./hex-id-recovery.js"; +import { + type IdArguments, + parseIdArguments, + parseTraceOrProjectArguments, +} from "./id-arguments.js"; import { logger } from "./logger.js"; import { guideOrgProjectFailure, @@ -37,9 +41,9 @@ import { resolveOrgOptionalTarget, resolveProjectBoundTarget, } from "./resolve-target.js"; -import { applySentryUrlContext, parseSentryUrl } from "./sentry-url-parser.js"; +import { applySentryUrlContext } from "./sentry-url-parser.js"; import { setOrgProjectContext } from "./telemetry.js"; -import { isTraceId, validateTraceId } from "./trace-id.js"; +import { isTraceId } from "./trace-id.js"; const log = logger.withTag("trace-target"); @@ -144,8 +148,10 @@ export type ResolvedTraceOrg = { * - `/ ` → explicit * - ` ` → project-first bare target with org fallback * - * Extra positional arguments beyond the first two are ignored with a - * warning, matching the established pattern across CLI commands. + * A Sentry trace URL works too. Extra positional arguments beyond the first + * two are ignored with a warning, matching the established pattern across + * CLI commands. The `trace` rule of `grammar/id-arguments.pegjs` defines the + * forms. * * @param args - Positional arguments from CLI * @param usageHint - Usage example for error messages @@ -169,78 +175,51 @@ export function parseTraceTargetInput( args: readonly string[], usageHint: string, ): TraceTargetInput { - if (args.length === 0) { - throw new ContextError("Trace ID", usageHint, []); - } - - const first = args[0]; - if (first === undefined) { - throw new ContextError("Trace ID", usageHint, []); - } - - // Sentry trace URLs (often copy-pasted with a trailing slash) carry the - // trace ID and org in the path; slash-splitting them yields an empty or - // bogus trace ID segment (CLI-13F). - const urlTarget = traceTargetFromUrlInput(first); - if (urlTarget) { - return { ...urlTarget, warnings: [] }; - } - - // Warn about extra positional args that will be ignored - const warnings = - args.length > 2 - ? [ - `Extra arguments ignored: ${args.slice(2).join(" ")}. Expected: ${usageHint}`, - ] - : []; - - // Two+ args: first is target context, second is trace ID - const second = args[1]; - if (second === undefined) { - return { - parsed: parseSlashSeparatedTraceTarget(first, usageHint), - warnings, - }; - } - - const traceId = validateTraceId(second); - return { ...targetArgToTraceTargetInput(first, traceId), warnings }; -} - -/** - * Build a trace target from a Sentry trace detail URL. - * - * Returns `null` when the input is not a Sentry URL or the URL has no trace - * ID. Side effect: configures `SENTRY_URL` for self-hosted hosts via - * {@link applySentryUrlContext} (throws `HostScopeError` for untrusted hosts). - * - * @internal Exported for testing - */ -export function traceTargetFromUrl(input: string): ParsedTraceTarget | null { - const urlTarget = traceTargetFromUrlInput(input); - if (!urlTarget) { - return null; - } - applySentryUrlContext(urlTarget.baseUrl); - return urlTarget.parsed; + return traceTargetInput( + parseIdArguments(args, "trace", { idLabel: "Trace ID", usageHint }), + ); } /** - * {@link traceTargetFromUrl} without side effects: returns the URL's server - * instead of applying it. + * Turn the grammar's {@link IdArguments} into a trace target. A separate + * target argument parses like any org/project target. */ -function traceTargetFromUrlInput( - input: string, -): { parsed: ParsedTraceTarget; baseUrl: string } | null { - const urlParsed = parseSentryUrl(input.trim()); - if (!urlParsed?.traceId) { - return null; +function traceTargetInput({ + id: traceId, + scope, + baseUrl, + warnings, +}: IdArguments): TraceTargetInput { + switch (scope.type) { + case "none": + return { parsed: { type: "auto-detect", traceId }, baseUrl, warnings }; + case "org": + return { + parsed: { type: "org-scoped", traceId, org: scope.org }, + baseUrl, + warnings, + }; + case "org-project": + return { + parsed: { + type: "explicit", + traceId, + org: scope.org, + project: scope.project, + }, + baseUrl, + warnings, + }; + case "target": + return { + ...targetArgToTraceTargetInput(scope.target, traceId), + warnings, + }; + default: { + const _exhaustive: never = scope; + throw new ValidationError(`Unexpected trace scope: ${_exhaustive}`); + } } - const traceId = validateTraceId(urlParsed.traceId); - const parsed: ParsedTraceTarget = urlParsed.org - ? { type: "org-scoped", traceId, org: urlParsed.org } - : { type: "auto-detect", traceId }; - return { parsed, baseUrl: urlParsed.baseUrl }; } /** @@ -273,66 +252,20 @@ export function stripTrailingSlashesAfterTraceId(input: string): string { * - One slash → `/` (org-scoped) * - Two+ slashes → split on last `/` → `//` (explicit) * + * The `tracePath` rule of `grammar/id-arguments.pegjs` defines the forms. + * * @internal Exported for testing */ export function parseSlashSeparatedTraceTarget( rawInput: string, usageHint: string, ): ParsedTraceTarget { - const input = stripTrailingSlashesAfterTraceId(rawInput); - const lastSlash = input.lastIndexOf("/"); - - if (lastSlash === -1) { - // No slashes — bare trace ID - return { type: "auto-detect", traceId: validateTraceId(input) }; - } - - const prefix = input.slice(0, lastSlash); - const rawTraceId = input.slice(lastSlash + 1); - - if (!rawTraceId) { - throw new ContextError("Trace ID", usageHint, []); - } - - const traceId = validateTraceId(rawTraceId); - - if (!prefix) { - // "/" — leading slash, treat as auto-detect - return { type: "auto-detect", traceId }; - } - - const innerSlash = prefix.indexOf("/"); - - if (innerSlash === -1) { - // "org/" — org-scoped - const ns = normalizeSlug(prefix); - return { - type: "org-scoped", - traceId, - org: ns.slug, - ...(ns.normalized && { normalized: true }), - }; - } - - // "org/project/" — explicit - const rawOrg = prefix.slice(0, innerSlash); - const rawProject = prefix.slice(innerSlash + 1); - - if (!(rawOrg && rawProject)) { - throw new ContextError("Trace ID", usageHint, []); - } - - const no = normalizeSlug(rawOrg); - const np = normalizeSlug(rawProject); - const normalized = no.normalized || np.normalized; - - return { - type: "explicit", - traceId, - org: no.slug, - project: np.slug, - ...(normalized && { normalized: true }), - }; + return traceTargetInput( + parseIdArguments([rawInput], "tracePath", { + idLabel: "Trace ID", + usageHint, + }), + ).parsed; } /** @@ -742,17 +675,11 @@ export type ParsedDualModeArgs = /** * Disambiguate positional arguments for dual-mode list commands. * - * Detects trace mode by checking whether any argument segment looks like - * a 32-char hex trace ID via {@link isTraceId}: - * - * - **No args**: project mode (auto-detect org/project) - * - **Two+ args**: checks the last positional. If it's a trace ID → trace - * mode (space-separated form like ` `). - * - **Single arg**: checks the tail segment (last part after `/`). If it - * looks like a trace ID → trace mode. Otherwise → project target. - * - * When trace mode is detected, delegates to {@link parseTraceTarget} for - * full parsing and validation. + * Trace mode when the arguments are a trace URL, end in a trace ID + * (` `), or start with `[/[/]]`; + * arguments the trace target does not use are ignored with a warning. + * Otherwise project mode, with the first argument as the target. The + * `traceOrProject` rule of `grammar/id-arguments.pegjs` defines the forms. * * @param args - Positional arguments from CLI * @param traceUsageHint - Usage hint for trace-mode error messages @@ -782,43 +709,8 @@ export function parseDualModeInput( args: readonly string[], traceUsageHint: string, ): DualModeInput { - if (args.length === 0) { - return { mode: "project" }; - } - - const first = args[0]; - if (first === undefined) { - return { mode: "project" }; - } - - // Two+ args: check if the last arg is a trace ID (space-separated form) - if (args.length >= 2) { - const last = args.at(-1); - if (last && isTraceId(last)) { - return { - mode: "trace", - trace: parseTraceTargetInput(args, traceUsageHint), - }; - } - } - - // Trace detail URL → trace mode (the URL itself carries the trace ID) - const urlTarget = traceTargetFromUrlInput(first); - if (urlTarget) { - return { mode: "trace", trace: { ...urlTarget, warnings: [] } }; - } - - // Single arg: check the tail segment (last part after "/", or entire arg) - const stripped = stripTrailingSlashesAfterTraceId(first); - const lastSlash = stripped.lastIndexOf("/"); - const tail = lastSlash === -1 ? stripped : stripped.slice(lastSlash + 1); - if (isTraceId(tail)) { - return { - mode: "trace", - trace: parseTraceTargetInput(args, traceUsageHint), - }; - } - - // Not a trace ID → treat as project target - return { mode: "project", target: first }; + const input = parseTraceOrProjectArguments(args, traceUsageHint); + return input.mode === "trace" + ? { mode: "trace", trace: traceTargetInput(input.trace) } + : input; } diff --git a/packages/cli/test/commands/replay/view.test.ts b/packages/cli/test/commands/replay/view.test.ts index 0e2c206f8..14ae6d5b8 100644 --- a/packages/cli/test/commands/replay/view.test.ts +++ b/packages/cli/test/commands/replay/view.test.ts @@ -5,7 +5,7 @@ import type { ArgumentParseError } from "@stricli/core"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { - parsePositionalArgs, + parseReplayArguments, type ReplayArguments, replayArguments, viewCommand, @@ -84,63 +84,104 @@ function sampleReplay(overrides: Partial = {}): ReplayDetails { }; } -describe("parsePositionalArgs", () => { +describe("parseReplayArguments", () => { + const ORG_ALL = { type: "org-all", org: "test-org" }; + const EXPLICIT = { type: "explicit", org: "test-org", project: "cli" }; + test("parses replay ID only", () => { - const result = parsePositionalArgs([REPLAY_ID]); + const result = parseReplayArguments([REPLAY_ID]); expect(result.replayId).toBe(REPLAY_ID); - expect(result.targetArg).toBeUndefined(); + expect(result.target).toEqual({ type: "auto-detect" }); }); test("parses org/replay-id shorthand", () => { - const result = parsePositionalArgs([`test-org/${REPLAY_ID}`]); + const result = parseReplayArguments([`test-org/${REPLAY_ID}`]); expect(result.replayId).toBe(REPLAY_ID); - expect(result.targetArg).toBe("test-org/"); + expect(result.target).toEqual(ORG_ALL); }); test("normalizes dashed org/replay-id shorthand", () => { - const result = parsePositionalArgs([ + const result = parseReplayArguments([ "test-org/346789a7-03f6-4543-84f1-de473b8b9fcc", ]); expect(result.replayId).toBe(REPLAY_ID); - expect(result.targetArg).toBe("test-org/"); + expect(result.target).toEqual(ORG_ALL); }); test("parses org/project/replay-id form", () => { - const result = parsePositionalArgs([`test-org/cli/${REPLAY_ID}`]); + const result = parseReplayArguments([`test-org/cli/${REPLAY_ID}`]); expect(result.replayId).toBe(REPLAY_ID); - expect(result.targetArg).toBe("test-org/cli"); + expect(result.target).toEqual(EXPLICIT); + }); + + test("ignores a slash pasted after the replay ID", () => { + const result = parseReplayArguments([`test-org/cli/${REPLAY_ID}/`]); + expect(result.replayId).toBe(REPLAY_ID); + expect(result.target).toEqual(EXPLICIT); + }); + + test("parses target and replay ID as two arguments", () => { + const result = parseReplayArguments(["test-org/cli", REPLAY_ID]); + expect(result.replayId).toBe(REPLAY_ID); + expect(result.target).toEqual(EXPLICIT); + expect(result.warnings).toEqual([]); }); test("parses replay URL", () => { - const result = parsePositionalArgs([ + const result = parseReplayArguments([ `https://sentry.io/organizations/test-org/explore/replays/${REPLAY_ID}/`, ]); expect(result.replayId).toBe(REPLAY_ID); - expect(result.targetArg).toBe("test-org/"); + expect(result.target).toEqual(ORG_ALL); + expect(result.baseUrl).toBe("https://sentry.io"); }); test("parses legacy replay URL", () => { - const result = parsePositionalArgs([ + const result = parseReplayArguments([ `https://test-org.sentry.io/replays/${REPLAY_ID}/`, ]); expect(result.replayId).toBe(REPLAY_ID); - expect(result.targetArg).toBe("test-org/"); + expect(result.target).toEqual(ORG_ALL); + }); + + test("throws ContextError for a Sentry URL without a replay ID", () => { + expect(() => + parseReplayArguments(["https://sentry.io/organizations/test-org/"]), + ).toThrow(ContextError); }); test("detects swapped args", () => { - const result = parsePositionalArgs([REPLAY_ID, "test-org/cli"]); + const result = parseReplayArguments([REPLAY_ID, "test-org/cli"]); expect(result.replayId).toBe(REPLAY_ID); - expect(result.targetArg).toBe("test-org/cli"); - expect(result.warning).toContain("reversed"); + expect(result.target).toEqual(EXPLICIT); + expect(result.warnings).toEqual([ + `Arguments appear reversed. Interpreting as: test-org/cli ${REPLAY_ID}`, + ]); + }); + + test("throws ContextError without arguments", () => { + expect(() => parseReplayArguments([])).toThrow(ContextError); + expect(() => parseReplayArguments([" "])).toThrow(ContextError); + expect(() => parseReplayArguments(["test-org/cli", ""])).toThrow( + ContextError, + ); }); test("throws ContextError for org/project with no replay ID", () => { - expect(() => parsePositionalArgs(["test-org/cli"])).toThrow(ContextError); + expect(() => parseReplayArguments(["test-org/cli"])).toThrow(ContextError); + expect(() => parseReplayArguments(["test-org/cli/"])).toThrow(ContextError); + }); + + test("throws ValidationError for an invalid replay ID", () => { + expect(() => parseReplayArguments(["not-hex"])).toThrow(ValidationError); + expect(() => parseReplayArguments(["test-org/cli", "not-hex"])).toThrow( + ValidationError, + ); }); test("throws ValidationError for extra positional args", () => { expect(() => - parsePositionalArgs(["test-org/cli", REPLAY_ID, "extra-arg"]), + parseReplayArguments(["test-org/cli", REPLAY_ID, "extra-arg"]), ).toThrow(ValidationError); }); }); @@ -426,7 +467,7 @@ describe("replayArguments", () => { replayId: REPLAY_ID, target: { type: "explicit", org: "test-org", project: "cli" }, baseUrl: undefined, - warning: undefined, + warnings: [], }); }); diff --git a/packages/cli/test/lib/id-arguments.test.ts b/packages/cli/test/lib/id-arguments.test.ts new file mode 100644 index 000000000..a57c3fb8a --- /dev/null +++ b/packages/cli/test/lib/id-arguments.test.ts @@ -0,0 +1,159 @@ +/** + * Tests for the ID-arguments grammar (grammar/id-arguments.pegjs), through + * src/lib/id-arguments.ts. + */ + +import { describe, expect, test } from "vitest"; +import { ContextError, ValidationError } from "../../src/lib/errors.js"; +import { + parseIdArguments, + parseTraceOrProjectArguments, +} from "../../src/lib/id-arguments.js"; + +const ID = "aaaa1111bbbb2222cccc3333dddd4444"; +const HINT = "sentry trace view [//]"; +const CONTEXT = { idLabel: "Trace ID", usageHint: HINT }; + +function trace(...args: string[]) { + return parseIdArguments(args, "trace", CONTEXT); +} + +describe("trace", () => { + test.each([ + [[ID], { type: "none" }], + [[`/${ID}`], { type: "none" }], + [[`${ID}//`], { type: "none" }], + [[`acme/${ID}`], { type: "org", org: "acme" }], + [[`acme/web/${ID}`], { type: "org-project", org: "acme", project: "web" }], + [[`acme/web/${ID}/`], { type: "org-project", org: "acme", project: "web" }], + [[`acme/a/b/${ID}`], { type: "org-project", org: "acme", project: "a/b" }], + [["acme/web", ID], { type: "target", target: "acme/web" }], + [["My Project", ID], { type: "target", target: "My Project" }], + ])("%j → %j", (args, scope) => { + expect(trace(...args)).toEqual({ id: ID, scope, warnings: [] }); + }); + + test("trims arguments and normalizes the ID", () => { + expect(trace(` acme/${ID.toUpperCase()}\n`)).toEqual({ + id: ID, + scope: { type: "org", org: "acme" }, + warnings: [], + }); + }); + + test("a trace URL wins over the other arguments", () => { + expect( + trace(`https://acme.sentry.io/explore/traces/trace/${ID}/`, "extra"), + ).toEqual({ + id: ID, + scope: { type: "org", org: "acme" }, + baseUrl: "https://acme.sentry.io", + warnings: [], + }); + }); + + test("warns about arguments after the trace ID", () => { + expect(trace("acme/web", ID, "a", "b").warnings).toEqual([ + `Extra arguments ignored: a b. Expected: ${HINT}`, + ]); + }); + + test.each([ + [[]], + [["acme/web/"]], + [["acme/"]], + [["/"]], + [[`acme//${ID}`]], + [[`/web/${ID}`]], + ])("%j → missing trace ID", (args) => { + expect(() => trace(...args)).toThrow(ContextError); + }); + + test.each([ + [[""]], + [["not-hex"]], + [["acme/not-hex"]], + [["acme/web", "not-hex"]], + ])("%j → invalid trace ID", (args) => { + expect(() => trace(...args)).toThrow(ValidationError); + }); + + test("rejects NUL in an argument", () => { + expect(() => trace(`acme\0web`, ID)).toThrow(ValidationError); + }); +}); + +describe("tracePath", () => { + test("does not read URLs", () => { + expect(() => + parseIdArguments( + [`https://acme.sentry.io/explore/traces/trace/${ID}`], + "tracePath", + CONTEXT, + ), + ).not.toThrow(); + expect( + parseIdArguments([`acme/web/${ID}`], "tracePath", CONTEXT).scope, + ).toEqual({ type: "org-project", org: "acme", project: "web" }); + }); +}); + +describe("traceOrProject", () => { + const parse = (...args: string[]) => parseTraceOrProjectArguments(args, HINT); + + test.each([ + [[], undefined], + [["acme/web"], "acme/web"], + [["acme/"], "acme/"], + [["web", "not-a-trace"], "web"], + ])("%j → project mode", (args, target) => { + expect(parse(...args)).toEqual({ mode: "project", target }); + }); + + test("a trailing trace ID selects trace mode", () => { + expect(parse("acme/web", ID)).toEqual({ + mode: "trace", + trace: { + id: ID, + scope: { type: "target", target: "acme/web" }, + warnings: [], + }, + }); + }); + + test("the trace ID is the last argument, the target the first", () => { + // The mode and the trace ID come from the same argument; a subcommand + // name or stray word in between is ignored, not read as the trace ID + expect(parse("acme/web", "view", ID)).toEqual({ + mode: "trace", + trace: { + id: ID, + scope: { type: "target", target: "acme/web" }, + warnings: [`Extra arguments ignored: view. Expected: ${HINT}`], + }, + }); + }); + + test("a trace ID path selects trace mode", () => { + expect(parse(`acme/${ID}/`)).toMatchObject({ + mode: "trace", + trace: { id: ID, scope: { type: "org", org: "acme" } }, + }); + expect(parse(`acme/web/${ID}`, "extra")).toMatchObject({ + mode: "trace", + trace: { + scope: { type: "org-project", org: "acme", project: "web" }, + warnings: [`Extra arguments ignored: extra. Expected: ${HINT}`], + }, + }); + }); + + test("a trace URL selects trace mode", () => { + expect( + parse(`https://sentry.io/organizations/acme/traces/${ID}/`), + ).toMatchObject({ + mode: "trace", + trace: { id: ID, scope: { type: "org", org: "acme" } }, + }); + }); +}); diff --git a/packages/cli/test/lib/trace-target.test.ts b/packages/cli/test/lib/trace-target.test.ts index dff3264cb..aa81a8246 100644 --- a/packages/cli/test/lib/trace-target.test.ts +++ b/packages/cli/test/lib/trace-target.test.ts @@ -37,7 +37,6 @@ import { resolveTraceOrgProject, stripTrailingSlashesAfterTraceId, targetArgToTraceTarget, - traceTargetFromUrl, } from "../../src/lib/trace-target.js"; const VALID_TRACE_ID = "aaaa1111bbbb2222cccc3333dddd4444"; @@ -329,9 +328,10 @@ describe("trailing slashes and Sentry trace URLs (CLI-13F)", () => { expect(result.traceId).toBe(VALID_TRACE_ID); }); - test("traceTargetFromUrl returns null for non-URL input", () => { - expect(traceTargetFromUrl(VALID_TRACE_ID)).toBeNull(); - expect(traceTargetFromUrl(`my-org/${VALID_TRACE_ID}`)).toBeNull(); + test("a Sentry URL without a trace ID is not a trace URL", () => { + expect(() => + parseTraceTarget(["https://sentry.io/organizations/my-org/"], HINT), + ).toThrow(ContextError); }); test("org/project// → explicit", () => { diff --git a/scripts/generate-parsers.mjs b/scripts/generate-parsers.mjs index fb6248543..6508aa54c 100644 --- a/scripts/generate-parsers.mjs +++ b/scripts/generate-parsers.mjs @@ -8,7 +8,9 @@ * editors and linters check in place. The generator copies it to * `src/generated/-grammar.d.ts`. Relative module specifiers, found * with the TypeScript parser, are rebased so they resolve to the same - * modules from there; nothing else in the file changes. + * modules from there; nothing else in the file changes. The string literal + * types of its `startRule` properties are the grammar's allowed start rules + * (by default only the first rule). * * The CLI generates its parsers at build time (`pnpm run generate:grammar` * in packages/cli). @@ -81,6 +83,32 @@ function rebaseDeclarations(source, fileName) { return result; } +/** The string literals a `startRule` property's type allows. */ +function startRules(source, fileName) { + const file = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest); + const rules = new Set(); + const addLiterals = (type) => { + if (ts.isUnionTypeNode(type)) { + type.types.forEach(addLiterals); + } else if (ts.isLiteralTypeNode(type) && ts.isStringLiteral(type.literal)) { + rules.add(type.literal.text); + } + }; + const visit = (node) => { + if ( + ts.isPropertySignature(node) && + node.type && + ts.isIdentifier(node.name) && + node.name.text === "startRule" + ) { + addLiterals(node.type); + } + ts.forEachChild(node, visit); + }; + visit(file); + return [...rules]; +} + const grammars = (await readdir(grammarDir)).filter((file) => file.endsWith(".pegjs"), ); @@ -94,15 +122,14 @@ for (const file of grammars) { throw new Error(`${declarationsPath} is missing`); } + const declarationsSource = await readFile(declarationsPath, "utf-8"); const parserSource = peggy.generate(await readFile(grammarPath, "utf-8"), { output: "source", format: "es", grammarSource: relative(packageDir, grammarPath).split(sep).join("/"), + allowedStartRules: startRules(declarationsSource, declarationsPath), }); - const declarations = rebaseDeclarations( - await readFile(declarationsPath, "utf-8"), - declarationsPath, - ); + const declarations = rebaseDeclarations(declarationsSource, declarationsPath); const outputBase = join(outputDir, `${name}-grammar`); await writeFile(`${outputBase}.js`, `${HEADER}\n${parserSource}\n`); From 6bb2a8a5ecef1b36070891156d825aa7cff6ff30 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:28:46 +0000 Subject: [PATCH 05/13] fix(cli): Keep a path's trace ID, and reject empty path segments Addresses Warden on #1714. - A first argument that ends in a trace ID is the trace target, even when a second trace ID follows. `trace` and `traceOrProject` used to read it as the target of the second ID, so `trace logs acme/ ` used ``. The later arguments are now ignored with a warning, as after any other form. - `//` and `//` have an ID; an empty org or project segment is a `ValidationError` ("Invalid format"), not a missing ID. --- packages/cli/grammar/id-arguments.pegjs | 22 +++++++++++------- packages/cli/test/lib/id-arguments.test.ts | 27 +++++++++++++++++++--- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/packages/cli/grammar/id-arguments.pegjs b/packages/cli/grammar/id-arguments.pegjs index 7577da943..c09ef30a4 100644 --- a/packages/cli/grammar/id-arguments.pegjs +++ b/packages/cli/grammar/id-arguments.pegjs @@ -42,8 +42,8 @@ : []; } - // `[/[/]]`, as split by `idPath`. Without the ID, or with - // only one of `/`, the ID is missing. + // `[/[/]]`, as split by `idPath`. Without the ID, the ID + // is missing; with an empty `` or ``, the path is invalid. function fromPath(path, warnings = []) { if (path.id === "" && (path.root || path.org !== undefined)) { missing(); @@ -56,7 +56,10 @@ return { id: value, scope: { type: "org", org: path.org }, warnings }; } if (path.org === "" || path.project === "") { - missing(); + invalid( + `Invalid format: "${path.text}" needs an organization and a project before the ID.\n\nUsage: ${usageHint}`, + "positional", + ); } const { org, project } = path; return { id: value, scope: { type: "org-project", org, project }, warnings }; @@ -90,9 +93,11 @@ // `[/[/]]`, ` `, or a trace URL. // Arguments after those are ignored with a warning, or silently after a URL // (which may be followed by what the browser's address bar had around it). +// A first argument that ends in a trace ID is never a target. trace = u:traceUrl arg* { return fromUrl(u, u.traceId); } - / sep p:idPath end { return fromPath(p); } + / sep p:idPath x:arg* &{ return x.length === 0 || isId(p.id); } + { return fromPath(p, ignored(x)); } / t:arg i:arg x:arg* { return fromTarget(t, i, ignored(x)); } / end { missing(); } @@ -105,12 +110,13 @@ tracePath traceOrProject = end { return { mode: "project" }; } / u:traceUrl arg* { return traceMode(fromUrl(u, u.traceId)); } + // `[/[/]]`, before a trailing trace ID is read + // as the trace's + / sep p:idPath x:arg* &{ return isId(p.id); } + { return traceMode(fromPath(p, ignored(x))); } // ` ` / t:arg x:(!lastArg @arg)* i:lastArg &{ return isId(i); } { return traceMode(fromTarget(t, i, ignored(x))); } - // `[/[/]]` - / sep p:idPath x:arg* &{ return isId(p.id); } - { return traceMode(fromPath(p, ignored(x))); } / t:arg arg* { return { mode: "project", target: t }; } // `[/[/]]`, ` `, or a replay URL @@ -159,7 +165,7 @@ idPath / "/" i:idSegment argEnd { return { id: i, root: true }; } / o:$[^/\0]+ "/" i:idSegment argEnd { return { id: i, org: o }; } / o:$[^/\0]* "/" p:$(!("/" idSegment argEnd) [^\0])* "/" i:idSegment argEnd - { return { id: i, org: o, project: p }; } + { return { id: i, org: o, project: p, text: text() }; } // A segment, and the slashes after it when it is an ID idSegment diff --git a/packages/cli/test/lib/id-arguments.test.ts b/packages/cli/test/lib/id-arguments.test.ts index a57c3fb8a..7363dec01 100644 --- a/packages/cli/test/lib/id-arguments.test.ts +++ b/packages/cli/test/lib/id-arguments.test.ts @@ -52,6 +52,15 @@ describe("trace", () => { }); }); + test("a trace ID path is not a target for a second trace ID", () => { + const other = "bbbb2222cccc3333dddd4444eeee5555"; + expect(trace(`acme/web/${ID}`, other)).toEqual({ + id: ID, + scope: { type: "org-project", org: "acme", project: "web" }, + warnings: [`Extra arguments ignored: ${other}. Expected: ${HINT}`], + }); + }); + test("warns about arguments after the trace ID", () => { expect(trace("acme/web", ID, "a", "b").warnings).toEqual([ `Extra arguments ignored: a b. Expected: ${HINT}`, @@ -63,8 +72,6 @@ describe("trace", () => { [["acme/web/"]], [["acme/"]], [["/"]], - [[`acme//${ID}`]], - [[`/web/${ID}`]], ])("%j → missing trace ID", (args) => { expect(() => trace(...args)).toThrow(ContextError); }); @@ -74,7 +81,9 @@ describe("trace", () => { [["not-hex"]], [["acme/not-hex"]], [["acme/web", "not-hex"]], - ])("%j → invalid trace ID", (args) => { + [[`acme//${ID}`]], + [[`/web/${ID}`]], + ])("%j → invalid", (args) => { expect(() => trace(...args)).toThrow(ValidationError); }); @@ -148,6 +157,18 @@ describe("traceOrProject", () => { }); }); + test("a trace ID path wins over a trailing trace ID", () => { + const other = "bbbb2222cccc3333dddd4444eeee5555"; + expect(parse(`acme/${ID}`, other)).toEqual({ + mode: "trace", + trace: { + id: ID, + scope: { type: "org", org: "acme" }, + warnings: [`Extra arguments ignored: ${other}. Expected: ${HINT}`], + }, + }); + }); + test("a trace URL selects trace mode", () => { expect( parse(`https://sentry.io/organizations/acme/traces/${ID}/`), From e524d644b57dbeecfe881e90630a0d25baf34105 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:35:31 +0000 Subject: [PATCH 06/13] style(cli): Format id-arguments tests --- packages/cli/test/lib/id-arguments.test.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/cli/test/lib/id-arguments.test.ts b/packages/cli/test/lib/id-arguments.test.ts index 7363dec01..c8d3d8826 100644 --- a/packages/cli/test/lib/id-arguments.test.ts +++ b/packages/cli/test/lib/id-arguments.test.ts @@ -67,14 +67,12 @@ describe("trace", () => { ]); }); - test.each([ - [[]], - [["acme/web/"]], - [["acme/"]], - [["/"]], - ])("%j → missing trace ID", (args) => { - expect(() => trace(...args)).toThrow(ContextError); - }); + test.each([[[]], [["acme/web/"]], [["acme/"]], [["/"]]])( + "%j → missing trace ID", + (args) => { + expect(() => trace(...args)).toThrow(ContextError); + }, + ); test.each([ [[""]], From 0a4ad5a6d0e3b2a1a3c715685dbd8524642471f1 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:59:16 +0000 Subject: [PATCH 07/13] fix(cli): Parse a replay ID after an empty target Addresses Warden on #1714. `replay view "" ` (an unset `$ORG`, say) failed with "Replay ID is required": the empty-first-argument rule also matched when a replay ID followed. Only an empty single argument is a missing ID now; an empty target auto-detects, as for trace IDs. --- packages/cli/grammar/id-arguments.pegjs | 2 +- packages/cli/test/commands/replay/view.test.ts | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/cli/grammar/id-arguments.pegjs b/packages/cli/grammar/id-arguments.pegjs index c09ef30a4..9fe03758d 100644 --- a/packages/cli/grammar/id-arguments.pegjs +++ b/packages/cli/grammar/id-arguments.pegjs @@ -128,7 +128,7 @@ replay "positional", ); } - / sep argEnd arg? end { missing(); } + / sep end { missing(); } / u:arg &{ return url(u) !== null; } arg? end { const parsed = url(u); if (!(parsed.replayId && parsed.org)) { diff --git a/packages/cli/test/commands/replay/view.test.ts b/packages/cli/test/commands/replay/view.test.ts index 14ae6d5b8..ae391d0bb 100644 --- a/packages/cli/test/commands/replay/view.test.ts +++ b/packages/cli/test/commands/replay/view.test.ts @@ -167,6 +167,13 @@ describe("parseReplayArguments", () => { ); }); + test("an empty target before the replay ID auto-detects", () => { + // e.g. `sentry replay view "$ORG" ` with `$ORG` unset + const result = parseReplayArguments(["", REPLAY_ID]); + expect(result.replayId).toBe(REPLAY_ID); + expect(result.target).toEqual({ type: "auto-detect" }); + }); + test("throws ContextError for org/project with no replay ID", () => { expect(() => parseReplayArguments(["test-org/cli"])).toThrow(ContextError); expect(() => parseReplayArguments(["test-org/cli/"])).toThrow(ContextError); From 11d9eb00cf0b5b4ed89fb97742991e6d26a11914 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:11:18 +0000 Subject: [PATCH 08/13] ref(cli): Parse every kind of ID argument with one grammar The ID-arguments grammar no longer knows trace or replay IDs. It has one start rule, and the command says what its IDs look like through an ID_KINDS entry (trace, replay, log, span): the predicate, the validator, and which part of a Sentry URL holds one. A bare 32-hex value never tells the parser its kind. Every ID-taking command now takes the same forms: ``, `/`, `//`, ` `, ` /` (reversed, with a warning), a URL, and with `multiple`, more IDs that may repeat the target. One argument may hold several lines, or IDs after a space. Extra arguments are ignored with a warning, keeping the first real ID. `optional` covers `log list` and `span list`; `needsProject` makes `/` name project `` for commands that need one; `validate: false` leaves malformed IDs for recovery. Migrated span view and log view as well. They drop their hand-written splitting, swap detection, and full-path handling. Trace view drops its swap detection, and trace recovery reuses the raw parse instead of re-deriving the ID's slot. A new sentry-cli/no-ad-hoc-id-arguments lint rule rejects ID parsing by hand in commands (parseSlashSeparatedArg, detectSwappedViewArgs, HEX_ID_RE, SPAN_ID_RE), with event view allowed since it takes issue IDs, and rejects ID kinds defined outside ID_KINDS. The grammar tests run one table against every kind. Co-Authored-By: BYK --- .oxlintrc.json | 1 + packages/cli/AGENTS.md | 48 ++- packages/cli/grammar/id-arguments.d.ts | 70 ++-- packages/cli/grammar/id-arguments.pegjs | 240 ++++------- packages/cli/lint-rules/cli-oxlint-plugin.js | 56 +++ packages/cli/src/commands/log/view.ts | 235 ++--------- packages/cli/src/commands/replay/view.ts | 51 +-- packages/cli/src/commands/span/list.ts | 2 +- packages/cli/src/commands/span/view.ts | 250 ++++-------- packages/cli/src/commands/trace/view.ts | 76 +--- packages/cli/src/lib/hex-id.ts | 7 + packages/cli/src/lib/id-arguments.ts | 372 +++++++++++++----- packages/cli/src/lib/trace-target.ts | 338 +++++----------- packages/cli/test/commands/log/view.test.ts | 2 +- .../cli/test/commands/replay/view.test.ts | 10 +- packages/cli/test/commands/span/view.test.ts | 333 +++++----------- packages/cli/test/commands/trace/view.test.ts | 39 +- packages/cli/test/lib/id-arguments.test.ts | 328 +++++++++------ packages/cli/test/lib/trace-target.test.ts | 112 +++--- scripts/cli-lint-rules.test.mjs | 36 ++ scripts/generate-parsers.mjs | 37 +- 21 files changed, 1156 insertions(+), 1487 deletions(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index 695f7bab6..15b8df462 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -68,6 +68,7 @@ "sentry-cli/no-args-join-in-release": "error", "sentry-cli/no-direct-target-resolution": "error", "sentry-cli/require-target-resolution": "error", + "sentry-cli/no-ad-hoc-id-arguments": "error", "sentry-cli/no-generic-is-record": "error", "sentry-cli/prefer-paginate-helper": "error", "sentry-cli/no-manual-pagination": "error", diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 44153c365..ccd9d416e 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -227,18 +227,42 @@ export const myRoute = buildRouteMap({ ### Positional Arguments -Use `parseSlashSeparatedArg` from `src/lib/arg-parsing.ts` for the standard `[//]` pattern. Required identifiers (trace IDs, span IDs) should be **positional args**, not flags. +Required identifiers (trace IDs, span IDs) should be **positional args**, not +flags. Every command that takes trace, span, replay, or log IDs parses them +with `parseIdArguments` (or `parseIdArgument` for exactly one) from +`src/lib/id-arguments.ts`, so they all take the same forms: + +- ``, `/`, `//`, ` `, a Sentry URL +- ` /`, reversed, with a warning +- more IDs after any of those (with `multiple`), each maybe repeating the + target; one argument may hold several lines, or IDs after a space +- extra arguments are ignored with a warning; slashes pasted after an ID are + ignored + +The command passes its kind of ID from `ID_KINDS` (what the ID looks like, +how to validate it, which part of a URL holds one) and how it takes IDs: +`optional` when it can list a project's items instead (`log list`, +`span list`), `needsProject` when `/` should name project `` +rather than org ``, `validate: false` to recover malformed IDs once the +target is known. The result is a `target` string for `parseOrgProjectInput` +and the IDs. Add a kind to `ID_KINDS` rather than validating IDs in a command; +the `sentry-cli/no-ad-hoc-id-arguments` lint rule rejects hand-written ID +parsing (`parseSlashSeparatedArg`, `HEX_ID_RE`, ...) in commands and kinds +defined outside `ID_KINDS`. Issue IDs (and `event view`, which takes them) +keep their own syntax. ```typescript -import { parseSlashSeparatedArg, parseOrgProjectArg } from "../../lib/arg-parsing.js"; +import { ID_KINDS, parseIdArgument } from "../../lib/id-arguments.js"; -// "my-org/my-project/abc123" → { id: "abc123", targetArg: "my-org/my-project" } -const { id, targetArg } = parseSlashSeparatedArg(first, "Trace ID", USAGE_HINT); -const parsed = parseOrgProjectArg(targetArg); -// parsed.type: "auto-detect" | "explicit" | "project-search" | "org-all" +// ["my-org/my-project/abc123..."] → { target: "my-org/my-project", id: "abc123...", warnings: [] } +const { target, id, baseUrl, warnings } = parseIdArgument(args, ID_KINDS.replay, { + usageHint: USAGE_HINT, +}); +const parsed = parseOrgProjectInput(target); ``` -Reference: `span/list.ts`, `trace/view.ts`, `event/view.ts` +Reference: `replay/view.ts`, `span/view.ts`, `log/view.ts`, and +`trace-target.ts` for trace targets. **Target positionals:** A single `[/][]` positional uses a parser from `src/lib/target-positional.ts`, which binds the resolver: @@ -287,12 +311,10 @@ command's help instead. The same purity rule applies: return a pasted URL's serv this for trace targets). Which argument is the ID and which is its target is decided by -`grammar/id-arguments.pegjs` (through `parseIdArguments` in -`src/lib/id-arguments.ts`), not by counting arguments in TypeScript. Add a -start rule there for a new `[/[/]]` shape and list it in -the `startRule` types of `grammar/id-arguments.d.ts`; the generator compiles -every rule listed there as a start rule. Errors go through the grammar's -hooks, never Peggy's own syntax errors. +`grammar/id-arguments.pegjs`, not by counting arguments in TypeScript. The +grammar knows no kind of ID: the command's `ID_KINDS` entry tells it what an +ID looks like, so the syntax cannot drift between commands. It accepts any +input; errors come from `parseIdArguments`, never Peggy's own syntax errors. **Shell completion:** Target positionals that take an org, project, issue, or dashboard use a completion-aware parser from `src/lib/completion-target.ts` diff --git a/packages/cli/grammar/id-arguments.d.ts b/packages/cli/grammar/id-arguments.d.ts index 74dd7e572..6e291e1c6 100644 --- a/packages/cli/grammar/id-arguments.d.ts +++ b/packages/cli/grammar/id-arguments.d.ts @@ -1,31 +1,48 @@ // Types of the parser generated from id-arguments.pegjs. The generator // copies this file to src/generated/id-arguments-grammar.d.ts and rewrites -// relative imports to resolve from there. It also compiles every `startRule` -// listed here as a start rule. +// relative imports to resolve from there. -import type { - IdArguments, - TraceOrProjectArguments, -} from "../src/lib/id-arguments.js"; -import type { ParsedSentryUrl } from "../src/lib/sentry-url-parser.js"; +/** An ID a Sentry URL carries, with the URL's org and server. */ +export type IdUrl = { + readonly id: string; + readonly org?: string; + readonly baseUrl?: string; +}; /** - * Hooks the ID-arguments grammar calls to check IDs and to throw, so its - * errors match the rest of the CLI. See `parseIdArguments()`. + * What the command parses: the grammar itself does not know any kind of ID. + * See `parseIdArguments()`. */ export type IdArgumentsGrammarOptions = { - /** Usage shown when arguments are ignored or there are too many. */ - usageHint: string; - /** Whether `value` is an ID. */ + /** Whether the arguments may name no ID (then a lone token is a target). */ + optional: boolean; + /** Whether `value` is an ID of the kind the command takes. */ isId: (value: string) => boolean; - /** Normalize an ID; throw when `value` is not one. */ - id: (value: string) => string; - /** Parse a Sentry web URL, or return `null` for anything else. */ - sentryUrl: (value: string) => ParsedSentryUrl | null; - /** Throw for a missing ID. */ - missing: (suggestions?: string[]) => never; - /** Throw a validation error. */ - invalid: (message: string, field?: string) => never; + /** The ID in a Sentry URL, or `null` for anything else. */ + idUrl: (value: string) => IdUrl | null; +}; + +/** A token that names an ID, maybe after a target: `[/]`. */ +export type IdToken = { + /** The token as typed */ + readonly text: string; + /** What came before the ID's slash, if anything */ + readonly target?: string; + /** The ID, not validated */ + readonly id: string; +}; + +/** Which tokens are the target and which are IDs. */ +export type IdArgumentsSyntax = { + /** The target, as typed: a separate argument, a path prefix, or `/` */ + readonly target?: string; + /** Whether `target` came before the first ID in one path */ + readonly path?: boolean; + readonly ids: readonly IdToken[]; + /** Server of a pasted Sentry URL */ + readonly baseUrl?: string; + /** Whether the ID came before the target */ + readonly reversed?: boolean; }; export declare class SyntaxError extends Error { @@ -34,15 +51,8 @@ export declare class SyntaxError extends Error { location: unknown; } -/** Parse NUL-prefixed arguments that end in an ID. */ -export declare function parse( - input: string, - options: IdArgumentsGrammarOptions & { - startRule: "trace" | "tracePath" | "replay"; - }, -): IdArguments; -/** Parse NUL-prefixed arguments that list a project's or a trace's items. */ +/** Parse NUL-prefixed tokens into a target and IDs. */ export declare function parse( input: string, - options: IdArgumentsGrammarOptions & { startRule: "traceOrProject" }, -): TraceOrProjectArguments; + options: IdArgumentsGrammarOptions, +): IdArgumentsSyntax; diff --git a/packages/cli/grammar/id-arguments.pegjs b/packages/cli/grammar/id-arguments.pegjs index 9fe03758d..3d1606e1e 100644 --- a/packages/cli/grammar/id-arguments.pegjs +++ b/packages/cli/grammar/id-arguments.pegjs @@ -1,181 +1,111 @@ -// Grammar for positionals that end in an ID, where what an argument means -// depends on how many were given: `[/[/]]` in one -// argument, ` ` in two, or a Sentry URL. +// Grammar for positionals that name IDs and the org/project to look them up +// in. It knows nothing about what kind of ID it parses (trace, span, replay, +// log...): the command says which, through the `isId` and `idUrl` hooks in +// `options`, so every ID-taking command shares one syntax: +// +// the org/project is auto-detected +// / like a URL: the org, then the ID (the +// caller may read `` as a project) +// // +// any org/project target, as everywhere else +// / reversed; the caller warns +// a Sentry URL with the ID in it +// +// More IDs may follow any of these, each repeating the target or not +// (`/`). The ID is always the last path segment, and slashes +// pasted after it are ignored. +// // `pnpm run generate:grammar` compiles it, with the types in // id-arguments.d.ts, into the gitignored src/generated/; the build, // typecheck, and test scripts run it. // -// `parseIdArguments()` (src/lib/id-arguments.ts) parses every argument at -// once: each one is trimmed and follows a NUL, which no command-line argument -// contains. So no arguments is the empty input, and `[""]` is "\0". The -// grammar decides which argument is the ID and which is its target. ID checks -// and errors go through the `isId`, `id`, `sentryUrl`, `missing`, and -// `invalid` hooks in `options`, so they match the rest of the CLI. The result -// names the parts of the target (`IdArguments`); the caller turns them into -// a trace or org/project target. -// -// Start rules: -// - `trace`: trace view, trace logs, and span list in trace mode -// - `tracePath`: one argument and no URL (the trace part of span view's) -// - `traceOrProject`: log list and span list, which list a project's items -// or a trace's -// - `replay`: replay view and replay download +// `parseIdArguments()` (src/lib/id-arguments.ts) splits the arguments into +// tokens first and puts a NUL, which no argument contains, before each one. +// No token is empty. The grammar only decides which token is the target and +// which are IDs; it accepts any input and never throws. The caller counts +// the IDs, validates them, and parses the target like any org/project target. { - const { usageHint, isId, id, sentryUrl, missing, invalid } = options; + const { optional, isId, idUrl } = options; - const NO_SCOPE = { type: "none" }; - - // The parse is the same every time; a predicate and the action after it - // can both ask - const urls = new Map(); - function url(value) { - if (!urls.has(value)) { - urls.set(value, sentryUrl(value)); - } - return urls.get(value); - } - - function ignored(extra) { - return extra.length > 0 - ? [`Extra arguments ignored: ${extra.join(" ")}. Expected: ${usageHint}`] - : []; - } - - // `[/[/]]`, as split by `idPath`. Without the ID, the ID - // is missing; with an empty `` or ``, the path is invalid. - function fromPath(path, warnings = []) { - if (path.id === "" && (path.root || path.org !== undefined)) { - missing(); - } - const value = id(path.id); - if (path.org === undefined) { - return { id: value, scope: NO_SCOPE, warnings }; - } - if (path.project === undefined) { - return { id: value, scope: { type: "org", org: path.org }, warnings }; - } - if (path.org === "" || path.project === "") { - invalid( - `Invalid format: "${path.text}" needs an organization and a project before the ID.\n\nUsage: ${usageHint}`, - "positional", - ); + // `[/[/]]` before an ID; the caller makes it a target + function pathTarget(prefix) { + if (prefix === "") { + return {}; } - const { org, project } = path; - return { id: value, scope: { type: "org-project", org, project }, warnings }; - } - - // ` `; the caller parses the target - function fromTarget(target, value, warnings = []) { - return { id: id(value), scope: { type: "target", target }, warnings }; - } - - // A Sentry URL with the ID in it - function fromUrl(parsed, value) { - return { - id: id(value), - scope: parsed.org ? { type: "org", org: parsed.org } : NO_SCOPE, - baseUrl: parsed.baseUrl, - warnings: [], - }; - } - - function traceMode(trace) { - return { mode: "trace", trace }; + return { target: prefix, path: true }; } } // Peggy runs a rule's action as soon as that rule matches, even when the -// enclosing alternative fails later and the parser backtracks. The rules -// below the start rules therefore only capture text, and the hooks that -// throw run in the action of the start rule alternative that wins. +// enclosing alternative fails later and the parser backtracks. Every action +// here only builds values, and the hooks are pure, so that is harmless. -// `[/[/]]`, ` `, or a trace URL. -// Arguments after those are ignored with a warning, or silently after a URL -// (which may be followed by what the browser's address bar had around it). -// A first argument that ends in a trace ID is never a target. -trace - = u:traceUrl arg* { return fromUrl(u, u.traceId); } - / sep p:idPath x:arg* &{ return x.length === 0 || isId(p.id); } - { return fromPath(p, ignored(x)); } - / t:arg i:arg x:arg* { return fromTarget(t, i, ignored(x)); } - / end { missing(); } - -// `[/[/]]` in one argument -tracePath - = sep p:idPath end { return fromPath(p); } - -// A trace's items when the arguments end in a trace ID or are a trace URL, -// otherwise a project's (`target` is the first argument, if any). -traceOrProject - = end { return { mode: "project" }; } - / u:traceUrl arg* { return traceMode(fromUrl(u, u.traceId)); } - // `[/[/]]`, before a trailing trace ID is read - // as the trace's - / sep p:idPath x:arg* &{ return isId(p.id); } - { return traceMode(fromPath(p, ignored(x))); } - // ` ` - / t:arg x:(!lastArg @arg)* i:lastArg &{ return isId(i); } - { return traceMode(fromTarget(t, i, ignored(x))); } - / t:arg arg* { return { mode: "project", target: t }; } - -// `[/[/]]`, ` `, or a replay URL -replay - = end { missing(); } - / a:arg|3..| { - invalid( - `Too many positional arguments (got ${a.length}, expected at most 2).\n\nUsage: ${usageHint}`, - "positional", - ); +arguments + = end { return { ids: [] }; } + // A Sentry URL with the ID in it + / sep u:urlWithId x:more { + return { + ...(u.org && { target: `${u.org}/` }), + ids: [{ text: u.id, id: u.id }, ...x], + ...(u.baseUrl && { baseUrl: u.baseUrl }), + }; } - / sep end { missing(); } - / u:arg &{ return url(u) !== null; } arg? end { - const parsed = url(u); - if (!(parsed.replayId && parsed.org)) { - missing([ - "Pass a replay URL: https://sentry.io/organizations/{org}/explore/replays/{replayId}/", - ]); - } - return fromUrl(parsed, parsed.replayId); + // Any other URL is a target + / sep t:url x:more { return { target: t, ids: x }; } + // ` /`: the arguments are reversed. When IDs are + // optional, only a real ID can come first. + / sep i:segment &{ return !optional || isId(i); } sep !idPath + t:$([^/\0]* "/" [^\0]*) end + { return { target: t, ids: [{ text: i, id: i }], reversed: true }; } + // `[/[/]] [...]` + / sep p:idPath x:more { + return { + ...pathTarget(p.target ?? ""), + ids: [{ text: p.text, id: p.id }, ...x], + }; } - // `/` without the replay ID - / sep p:idPath end - &{ return p.org !== undefined && p.project === undefined && !isId(p.id); } - { missing(); } - / sep p:idPath end { return fromPath(p); } - / arg sep end { missing(); } - // ` /`: the arguments are reversed - / sep i:$[^/\0]+ sep t:$([^/\0]* "/" [^\0]*) end { - return fromTarget(t, i, [ - `Arguments appear reversed. Interpreting as: ${t} ${i}`, - ]); + // One token that does not end in an ID. Unless IDs are optional, it still + // names one, so it can be reported or recovered: the last segment after + // two slashes (`//`), or the whole token. With one slash, + // `/` is a target without an ID. + / sep !"/" t:$(segment "/" segment "/" [^\0]*) end &{ return !optional; } { + const slash = t.lastIndexOf("/"); + return { + ...pathTarget(t.slice(0, slash)), + ids: [{ text: t, id: t.slice(slash + 1) }], + }; } - / t:arg i:arg end { return fromTarget(t, i); } + / sep i:segment end &{ return !optional; } { return { ids: [{ text: i, id: i }] }; } + // ` [...]` + / sep t:token x:more { return { target: t, ids: x }; } -// A Sentry URL with a trace ID in it -traceUrl - = u:arg &{ return Boolean(url(u)?.traceId); } { return url(u); } +// The IDs after the first token. Each may repeat the target: +// `/`. +more + = (sep @(idPath / t:token { return { text: t, id: t }; }))* -// `[/[/]]` in one argument. The ID is the last segment, -// even when slashes follow it (pasted from a URL); otherwise a trailing slash -// leaves the ID out. `` is everything before the first slash and -// `` the rest. +// `[/]` in one token: the last segment is an ID, maybe with +// slashes pasted after it. idPath - = i:idSegment argEnd { return { id: i }; } - / "/" i:idSegment argEnd { return { id: i, root: true }; } - / o:$[^/\0]+ "/" i:idSegment argEnd { return { id: i, org: o }; } - / o:$[^/\0]* "/" p:$(!("/" idSegment argEnd) [^\0])* "/" i:idSegment argEnd - { return { id: i, org: o, project: p, text: text() }; } + = t:$(!("/" idSegment argEnd) [^\0])* "/" i:idSegment argEnd + { return { text: text(), target: t, id: i }; } + / i:idSegment argEnd { return { text: text(), id: i }; } -// A segment, and the slashes after it when it is an ID +// An ID, and the slashes after it idSegment - = i:$[^/\0]* s:$"/"* &{ return s === "" || isId(i); } { return i; } + = i:segment "/"* &{ return isId(i); } { return i; } + +// A Sentry URL carrying the ID +urlWithId + = u:token &{ return idUrl(u) !== null; } { return idUrl(u); } + +url + = $("http" "s"? "://" [^\0]*) -// One argument -arg = sep @$[^\0]* +token = $[^\0]+ -// The last argument -lastArg = @arg end +segment = $[^/\0]+ sep = "\0" diff --git a/packages/cli/lint-rules/cli-oxlint-plugin.js b/packages/cli/lint-rules/cli-oxlint-plugin.js index ec2034d8f..637a0071a 100644 --- a/packages/cli/lint-rules/cli-oxlint-plugin.js +++ b/packages/cli/lint-rules/cli-oxlint-plugin.js @@ -147,6 +147,35 @@ function regexSource(node) { return ""; } +/** + * Helpers that parse ID positionals by hand. Commands parse IDs with + * `parseIdArguments()` (src/lib/id-arguments.ts), so every command takes + * the same forms. + */ +const AD_HOC_ID_PARSING = new Set([ + "parseSlashSeparatedArg", + "detectSwappedViewArgs", + "HEX_ID_RE", + "SPAN_ID_RE", +]); + +/** + * Commands still allowed to: `event view` also takes issue IDs, which are + * not hex IDs and have their own syntax. + */ +const AD_HOC_ID_PARSING_ALLOWED = ["/src/commands/event/view.ts"]; + +const ID_ARGUMENT_PARSERS = new Set(["parseIdArguments", "parseIdArgument"]); + +/** Whether `node` is `ID_KINDS.`. */ +function isIdKind(node) { + return ( + node?.type === "MemberExpression" && + node.object.type === "Identifier" && + node.object.name === "ID_KINDS" + ); +} + const rules = { "no-silent-catch": rule( "Handle or report caught errors instead of discarding them.", @@ -315,6 +344,33 @@ const rules = { }; }, ), + "no-ad-hoc-id-arguments": rule( + "Parse ID positionals with parseIdArguments() and an ID_KINDS kind.", + (_context, file, report) => { + if (!file.includes("/src/") || file.endsWith("/lib/id-arguments.ts")) + return {}; + const commands = + file.includes("/src/commands/") && + !AD_HOC_ID_PARSING_ALLOWED.some((allowed) => file.endsWith(allowed)); + return { + Identifier(node) { + if ( + commands && + AD_HOC_ID_PARSING.has(node.name) && + node.parent?.type !== "ImportSpecifier" + ) + report(node); + }, + CallExpression(node) { + if ( + ID_ARGUMENT_PARSERS.has(node.callee.name) && + !isIdKind(node.arguments[1]) + ) + report(node); + }, + }; + }, + ), "no-generic-is-record": rule( "Use a shape-specific guard instead of isRecord().", (_context, file, report) => diff --git a/packages/cli/src/commands/log/view.ts b/packages/cli/src/commands/log/view.ts index 38cab4a19..e04f975e8 100644 --- a/packages/cli/src/commands/log/view.ts +++ b/packages/cli/src/commands/log/view.ts @@ -11,11 +11,8 @@ import pLimit from "p-limit"; import type { SentryContext } from "../../context.js"; import { getLogItemDetail, getLogs } from "../../lib/api-client.js"; import { - detectSwappedViewArgs, looksLikeIssueShortId, parseOrgProjectArg, - parseSlashSeparatedArg, - splitNewlineArg, } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; @@ -28,16 +25,12 @@ import { import { formatLogDetails } from "../../lib/formatters/index.js"; import { filterFields } from "../../lib/formatters/json.js"; import { CommandOutput } from "../../lib/formatters/output.js"; -import { - ageInDaysFromUuidV7, - HEX_ID_RE, - normalizeHexId, - validateHexId, -} from "../../lib/hex-id.js"; +import { ageInDaysFromUuidV7, validateHexId } from "../../lib/hex-id.js"; import { handleRecoveryResult, recoverHexId, } from "../../lib/hex-id-recovery.js"; +import { ID_KINDS, parseIdArguments } from "../../lib/id-arguments.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -70,211 +63,49 @@ type ViewFlags = { /** Usage hint for ContextError messages */ const USAGE_HINT = "sentry log view / [...]"; -/** - * Resolve a single-slash positional arg (`before/after`) as `org/log-id`, or - * return `null` when `after` is not a valid 32-char hex log ID and should fall - * through to `parseSlashSeparatedArg`. - * - * This guard must run before `parseSlashSeparatedArg` because that function - * throws `ContextError` for any single-slash arg, treating it as `org/project` - * with a missing log ID (CLI-1AK). - */ -function parseSingleSlashLogArg( - beforeSlash: string, - afterSlash: string, -): { rawLogIds: string[]; targetArg: string | undefined } | null { - // "org/LOG-ID" or "project/LOG-ID" → treat beforeSlash as target, afterSlash as log ID. - if (afterSlash && HEX_ID_RE.test(normalizeHexId(afterSlash))) { - return { - rawLogIds: [normalizeHexId(afterSlash)], - targetArg: beforeSlash || undefined, - }; - } - return null; -} - -/** - * Split a fully-qualified `//` arg into its target and - * log ID, or return `null` when the arg isn't in that shape (fewer than two - * slashes, or the trailing segment isn't a 32-char hex ID). - * - * Agents often pass several fully-qualified paths to one `log view` call - * (CLI-1XC). Without this, the first path was used verbatim as the target - * (yielding project `"/"`) and the rest failed hex validation. - */ -function splitFullLogPath( - arg: string, -): { target: string; logId: string } | null { - const trimmed = arg.trim(); - const firstSlash = trimmed.indexOf("/"); - const lastSlash = trimmed.lastIndexOf("/"); - if (firstSlash === -1 || firstSlash === lastSlash) { - return null; - } - const logId = normalizeHexId(trimmed.slice(lastSlash + 1)); - if (!HEX_ID_RE.test(logId)) { - return null; - } - return { target: trimmed.slice(0, lastSlash), logId }; -} - -/** - * Parse multiple args where the first is a fully-qualified log path. - * Every later arg may be a fully-qualified path or a bare log ID; all paths - * must share the same org/project target. - * - * @throws {ValidationError} If the paths reference different targets - */ -function parseMultipleFullPaths( - args: string[], - target: string, -): { rawLogIds: string[]; targetArg: string } { - const rawLogIds: string[] = []; - for (const arg of args) { - const full = splitFullLogPath(arg); - if (full) { - if (full.target !== target) { - throw new ValidationError( - `Log IDs reference different projects ('${target}' and '${full.target}'). ` + - `View logs from one project per call: ${USAGE_HINT}`, - ); - } - rawLogIds.push(full.logId); - } else { - rawLogIds.push(...splitNewlineArg(arg)); - } - } - return { rawLogIds, targetArg: target }; -} - -/** - * Strip a redundant `/` prefix from a log ID arg, so - * `sentry log view org/proj org/proj/` resolves `` (CLI-1XC). - */ -function stripTargetPrefix(rawId: string, target: string): string { - const prefix = `${target.trim()}/`; - return rawId.startsWith(prefix) ? rawId.slice(prefix.length) : rawId; -} - /** * Parse positional arguments for log view. - * Handles: - * - `` — single log ID (auto-detect org/project) - * - ` [...]` — explicit target + one or more log IDs - * - `//` — single slash-separated arg * - * When two or more args are provided, the first is always treated as the - * target (org/project specifier) and the rest as log IDs. - * - * Arguments containing newlines are split into multiple IDs. + * Log IDs take the forms every ID-taking command does (see + * `id-arguments.ts`): ``, `/`, + * `//`, ` `, reversed, and more log + * IDs after any of those, each maybe repeating the target. An argument may + * hold several lines, one log ID each. * * Returns **raw** log IDs without running {@link validateHexId} — validation * is deferred to the main command so {@link recoverHexId} can use the * resolved org/project context for fuzzy prefix lookups. * * @param args - Positional arguments from CLI - * @returns Parsed raw log IDs and optional target arg - * @throws {ContextError} If no arguments provided + * @returns Parsed raw log IDs, optional target arg, and warnings + * @throws {ContextError} If no log ID is given + * @throws {ValidationError} If the log IDs name different projects */ -// positional arg parsing has many format branches by design -export function parsePositionalArgs(args: string[]): { +export function parsePositionalArgs(args: readonly string[]): { rawLogIds: string[]; targetArg: string | undefined; + /** Warnings to log, such as for reversed arguments */ + warnings: readonly string[]; /** Suggestion when first arg looks like an issue short ID */ suggestion?: string; } { - if (args.length === 0) { - throw new ContextError("Log ID", USAGE_HINT, []); - } - - const first = args[0]; - if (first === undefined) { - throw new ContextError("Log ID", USAGE_HINT, []); - } - - if (args.length === 1) { - // Single arg — could be slash-separated org/project/logId, org/logId, or a plain ID - // (possibly containing newlines). - // Guard: detect exactly-one-slash args before parseSlashSeparatedArg, which - // throws ContextError for "org/logId" thinking it's "org/project" with no ID. - const trimmedFirst = first.trim(); - const slashIdx = trimmedFirst.indexOf("/"); - if (slashIdx !== -1 && trimmedFirst.indexOf("/", slashIdx + 1) === -1) { - const singleSlash = parseSingleSlashLogArg( - trimmedFirst.slice(0, slashIdx), - trimmedFirst.slice(slashIdx + 1), - ); - if (singleSlash) { - if (singleSlash.rawLogIds.length === 0) { - throw new ContextError("Log ID", USAGE_HINT, []); - } - return singleSlash; - } - } - - const { id, targetArg } = parseSlashSeparatedArg( - first, - "Log ID", - USAGE_HINT, - ); - const rawLogIds = splitNewlineArg(id); - if (rawLogIds.length === 0) { - throw new ContextError("Log ID", USAGE_HINT, []); - } - return { rawLogIds, targetArg }; - } - - // Two or more args — first is target, rest are log IDs. - // Each arg may contain newlines (split them). - - // Check issue short ID first — it takes precedence over swap detection - // because `detectSwappedViewArgs` also fires for `CAM-82X my-org/project` - // (first has no "/", second has "/"), but the user's intent is clearly - // to view an issue, not to swap log-view arguments. - if (looksLikeIssueShortId(first)) { - const rawLogIds = args.slice(1).flatMap(splitNewlineArg); - if (rawLogIds.length === 0) { - throw new ContextError("Log ID", USAGE_HINT, []); - } - return { - rawLogIds, - targetArg: first, - suggestion: `Did you mean: sentry issue view ${first}`, - }; - } - - // oxlint-disable-next-line typescript/no-non-null-assertion -- length >= 2 guarantees index 1 exists - const second = args[1]!; - - // Detect swapped args: exactly two args where first has no "/" and second - // does (e.g., `sentry log view ace106b2 myorg/myproject`). Only fires for - // the two-arg case to avoid silently dropping extra args in multi-arg use. - if (args.length === 2) { - const swapWarning = detectSwappedViewArgs(first, second); - if (swapWarning) { - return { - rawLogIds: splitNewlineArg(first), - targetArg: second, - suggestion: swapWarning, - }; - } - } - - // Every arg is (or starts with) a fully-qualified `org/project/log-id`. - const firstFull = splitFullLogPath(first); - if (firstFull) { - return parseMultipleFullPaths(args, firstFull.target); - } - - const rawLogIds = args - .slice(1) - .flatMap(splitNewlineArg) - .map((id) => stripTargetPrefix(id, first)); - if (rawLogIds.length === 0) { - throw new ContextError("Log ID", USAGE_HINT, []); - } - - return { rawLogIds, targetArg: first }; + const { target, ids, warnings } = parseIdArguments(args, ID_KINDS.log, { + usageHint: USAGE_HINT, + multiple: true, + needsProject: true, + validate: false, + }); + const [first] = args; + return { + rawLogIds: [...ids], + targetArg: target, + warnings, + ...(args.length > 1 && + first && + looksLikeIssueShortId(first) && { + suggestion: `Did you mean: sentry issue view ${first}`, + }), + }; } /** @@ -622,7 +453,11 @@ export const viewCommand = buildCommand({ // Parse positional args (raw — validation is deferred to after target // resolution so we can run fuzzy recovery with org/project context). - const { rawLogIds, targetArg, suggestion } = parsePositionalArgs(args); + const { rawLogIds, targetArg, warnings, suggestion } = + parsePositionalArgs(args); + for (const warning of warnings) { + cmdLog.warn(warning); + } if (suggestion) { cmdLog.warn(suggestion); } diff --git a/packages/cli/src/commands/replay/view.ts b/packages/cli/src/commands/replay/view.ts index ab3fb1d8d..8111fb690 100644 --- a/packages/cli/src/commands/replay/view.ts +++ b/packages/cli/src/commands/replay/view.ts @@ -19,11 +19,7 @@ import { import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; import { ORG_PROJECT_TARGET } from "../../lib/completion-target.js"; -import { - ApiError, - ResolutionError, - ValidationError, -} from "../../lib/errors.js"; +import { ApiError, ResolutionError } from "../../lib/errors.js"; import { filterFields } from "../../lib/formatters/json.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { @@ -32,7 +28,7 @@ import { type ReplayViewData, replayHint, } from "../../lib/formatters/replay.js"; -import { type IdScope, parseIdArguments } from "../../lib/id-arguments.js"; +import { ID_KINDS, parseIdArgument } from "../../lib/id-arguments.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -73,13 +69,9 @@ const MAX_RELATED_TRACES = 2; const log = logger.withTag("replay.view"); /** - * Parse `replay view` and `replay download` arguments with the `replay` rule - * of `grammar/id-arguments.pegjs`: - * - `` - * - `/` - * - `//` - * - ` ` (or reversed, with a warning) - * - `` + * Parse `replay view` and `replay download` arguments: a replay ID in the + * forms every ID-taking command takes (see `id-arguments.ts`), or a replay + * URL. * * `usageHint` lets sibling replay commands show their own usage in errors. * Has no side effects: a pasted URL's server is returned as `baseUrl`. @@ -88,37 +80,20 @@ export function parseReplayArguments( args: readonly string[], usageHint = USAGE_HINT, ): ReplayArgumentsInput { - const { id, scope, baseUrl, warnings } = parseIdArguments(args, "replay", { - idLabel: "Replay ID", - usageHint, - }); - const target = parseOrgProjectInput(replayTargetArg(scope)); + const { target, id, baseUrl, warnings } = parseIdArgument( + args, + ID_KINDS.replay, + { usageHint }, + ); + const parsed = parseOrgProjectInput(target); return { replayId: id, - target: target.parsed, - baseUrl: baseUrl ?? target.baseUrl, + target: parsed.parsed, + baseUrl: baseUrl ?? parsed.baseUrl, warnings, }; } -/** The org/project target argument a replay's {@link IdScope} stands for. */ -function replayTargetArg(scope: IdScope): string | undefined { - switch (scope.type) { - case "none": - return; - case "org": - return `${scope.org}/`; - case "org-project": - return `${scope.org}/${scope.project}`; - case "target": - return scope.target; - default: { - const _exhaustive: never = scope; - throw new ValidationError(`Unexpected replay scope: ${_exhaustive}`); - } - } -} - /** Parsed `replay view` and `replay download` arguments. */ export type ReplayArgumentsInput = { readonly replayId: string; diff --git a/packages/cli/src/commands/span/list.ts b/packages/cli/src/commands/span/list.ts index 6b73a079a..2266b7e6c 100644 --- a/packages/cli/src/commands/span/list.ts +++ b/packages/cli/src/commands/span/list.ts @@ -197,7 +197,7 @@ export function parseSort(value: string): SpanSortValue { export function parseSpanListArgs( args: string[], ): ReturnType { - return parseDualModeArgs(args, TRACE_USAGE_HINT); + return parseDualModeArgs(args, TRACE_USAGE_HINT, { needsProject: true }); } // --------------------------------------------------------------------------- diff --git a/packages/cli/src/commands/span/view.ts b/packages/cli/src/commands/span/view.ts index a3ed686d7..d5db14264 100644 --- a/packages/cli/src/commands/span/view.ts +++ b/packages/cli/src/commands/span/view.ts @@ -28,11 +28,12 @@ import { import { filterFields } from "../../lib/formatters/json.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { computeSpanDurationMs } from "../../lib/formatters/time-utils.js"; -import { HEX_ID_RE, SPAN_ID_RE, validateSpanId } from "../../lib/hex-id.js"; +import { validateSpanId } from "../../lib/hex-id.js"; import { handleRecoveryResult, recoverHexId, } from "../../lib/hex-id-recovery.js"; +import { ID_KINDS, parseIdArguments } from "../../lib/id-arguments.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -40,7 +41,6 @@ import { } from "../../lib/list-command.js"; import { logger } from "../../lib/logger.js"; import { - parseSlashSeparatedTraceTarget, parseTraceTargetWithRecovery, resolveTraceOrgProject, warnIfNormalized, @@ -59,195 +59,88 @@ type ViewFlags = { const USAGE_HINT = "sentry span view [//] [...]"; -/** Result of the initial positional-arg parse for `span view`. */ -type SpanViewArgs = - | { - /** Args are already resolved (auto-split `/`). */ - kind: "resolved"; - traceTarget: ReturnType; - rawSpanIds: string[]; - } - | { - /** - * First arg is the raw trace target (needs async recovery); the rest - * are raw span IDs. Command layer calls `parseTraceTargetWithRecovery`. - */ - kind: "deferred"; - rawTraceArg: string; - rawSpanIds: string[]; - }; +/** The positional args of `span view`, parsed but not validated. */ +type SpanViewArgs = { + /** The trace: `[//]`, validated (or recovered) later */ + traceArg: string; + rawSpanIds: string[]; +}; /** * Parse positional arguments for span view. * - * Handles a few sync edge cases (single-arg `/` slash split, - * bare span ID without a trace) and returns either a fully-resolved - * target or a deferred form. In the deferred case the command layer calls - * {@link parseTraceTargetWithRecovery} on `rawTraceArg` so a malformed - * trace ID gets the full recovery treatment (matching `trace view` - * behavior). - * - * @throws {ContextError} If insufficient arguments or a single bare span ID - */ - -/** - * Try to auto-split a single arg of the form `/`. + * Span IDs take the forms every ID-taking command does (see + * `id-arguments.ts`), with the trace as their target: + * `[//] ...`, or + * `[//]/` in one argument (as `span list` + * prints them), with more span IDs after it. * - * Handles one-slash (`/`) and multi-slash - * (`org/project//`) forms. Returns null when the arg - * cannot be unambiguously split (e.g. left side is not a hex trace ID in - * the single-slash case). Throws a trace-focused {@link ContextError} when - * the arg clearly intends the `/` form but the - * trace-target portion is invalid (e.g. `org/project/`). + * IDs are returned raw: the command validates the trace with + * {@link parseTraceTargetWithRecovery} and the spans once it knows the + * trace, so malformed IDs can be recovered. * - * @throws {ContextError} When the trace-target prefix fails validation. + * @throws {ContextError} If the trace or the span IDs are missing */ -function tryAutoSplitSpanArg(arg: string): SpanViewArgs | null { - const lastSlashIdx = arg.lastIndexOf("/"); - if (lastSlashIdx === -1) { - return null; - } - - const tracePrefix = arg.slice(0, lastSlashIdx); - const possibleSpanId = arg - .slice(lastSlashIdx + 1) - .trim() - .toLowerCase() - .replace(/-/g, ""); - - if (!SPAN_ID_RE.test(possibleSpanId) || tracePrefix.length === 0) { - return null; - } - - const hasMultipleSlashes = tracePrefix.includes("/"); - const normalizedPrefix = tracePrefix.trim().toLowerCase().replace(/-/g, ""); +export function parsePositionalArgs(rawArgs: readonly string[]): SpanViewArgs { + // A lone argument that does not end in a span ID is the trace + const { target, ids } = parseIdArguments(rawArgs, ID_KINDS.span, { + usageHint: USAGE_HINT, + multiple: true, + optional: true, + needsProject: true, + validate: false, + }); + const [first] = ids; - // For single-slash form the prefix must be a hex trace ID. - // For multi-slash (org/project/trace-id) always attempt the split. - if (!(hasMultipleSlashes || HEX_ID_RE.test(normalizedPrefix))) { - return null; + if (target === undefined) { + // Span IDs without a trace: say so, rather than "invalid trace ID" + // (CLI-SC) + throw new ContextError( + "Trace ID and span ID", + USAGE_HINT, + first && ID_KINDS.span.isId(first) + ? [ + `'${first}' looks like a span ID (16 characters), not a trace ID`, + `Provide the trace ID first: sentry span view ${validateSpanId(first)}`, + `Use 'sentry trace list' to find trace IDs`, + ] + : [], + ); } - let traceTarget: ReturnType; - try { - traceTarget = parseSlashSeparatedTraceTarget(tracePrefix, USAGE_HINT); - } catch (_error) { - // We already confirmed the last segment is a span ID and the prefix - // looked like a trace target, so a validation failure here means the - // trace-target portion is bad — e.g. `org/project/` (missing - // the trace ID) or `org/project//`. Surface a - // trace-focused ContextError that preserves the underlying detail - // rather than falling through to the misleading "missing span ID" path. - const suggestion = HEX_ID_RE.test(tracePrefix.split("/").pop() || "") - ? `Provide the correct trace ID: sentry span view ${tracePrefix} ${possibleSpanId}` - : `Provide the trace ID: sentry span view ${tracePrefix}/ ${possibleSpanId}`; - throw new ContextError("Trace ID", USAGE_HINT, [ - `'${arg}' is missing a valid trace ID before the span ID '${possibleSpanId}'`, - suggestion, + if (first === undefined) { + throw new ContextError("Span ID", USAGE_HINT, [ + `Use 'sentry span list ${target}' to find span IDs within this trace`, ]); } - log.warn( - `Interpreting '${arg}' as /. ` + - `Use separate arguments: sentry span view ${tracePrefix} ${possibleSpanId}`, - ); - return { kind: "resolved", traceTarget, rawSpanIds: [possibleSpanId] }; -} -/** Splits a string on any run of whitespace (spaces, tabs, newlines). */ -const WHITESPACE_SPLIT_RE = /\s+/; + requireTraceId(target, first); + return { traceArg: target, rawSpanIds: [...ids] }; +} /** - * Split positional args on whitespace and drop empty tokens. + * Throw a trace-focused error when the target of the span IDs has no trace + * ID, as in `//` (CLI-1GP). A malformed trace ID is + * left for {@link parseTraceTargetWithRecovery}. * - * AI agents and scripts frequently pass the whole positional list as a - * single quoted argument — e.g. `"org/project/ "` or - * `"/\n"`. The shell never splits those, so - * without this normalization the span IDs are invisible to the parser and - * the command fails with "Span ID is required" (CLI-1BD). Trace IDs, span - * IDs, and org/project slugs never contain whitespace, so splitting is safe. + * @throws {ContextError} When the trace ID is missing */ -function splitWhitespaceArgs(args: string[]): string[] { - return args - .flatMap((arg) => arg.split(WHITESPACE_SPLIT_RE)) - .filter((token) => token.length > 0); -} - -export function parsePositionalArgs(rawArgs: string[]): SpanViewArgs { - const args = splitWhitespaceArgs(rawArgs); - const wasSplit = - args.length !== rawArgs.length || - args.some((token, i) => token !== rawArgs[i]); - if (wasSplit && args.length > 0) { - log.warn( - `Split whitespace-separated arguments into: ${args.join(" ")}. ` + - `Pass each ID as a separate argument: ${USAGE_HINT}`, - ); - } - - if (args.length === 0) { - throw new ContextError("Trace ID and span ID", USAGE_HINT, []); - } - - const first = args[0]; - if (first === undefined) { - throw new ContextError("Trace ID and span ID", USAGE_HINT, []); - } - - // Auto-detect single-arg forms where the last slash-segment is a span ID. - // This handles: - // / (one slash) - // [/][/]/ (two+ slashes, e.g. copy-paste from `span list`) - // Without this check, parseSlashSeparatedTraceTarget treats the span - // ID as a trace ID and fails validation (CLI-G6 / CLI-1BD). - if (args.length === 1) { - const resolved = tryAutoSplitSpanArg(first); - if (resolved) { - return resolved; - } - } - - // `/ ...` — the first token carries the - // first span ID and the remaining tokens are additional span IDs (e.g. a - // newline-separated list pasted after the slash form). - if (args.length > 1) { - let resolved: SpanViewArgs | null = null; - try { - resolved = tryAutoSplitSpanArg(first); - } catch { - // Not a valid `/` token; keep the standard - // ` ...` interpretation below. - resolved = null; - } - if (resolved) { - return { - ...resolved, - rawSpanIds: [...resolved.rawSpanIds, ...args.slice(1)], - }; - } - } - - // Single bare arg that looks like a span ID (16-char hex, no slashes): - // the user forgot the trace ID. Give a targeted ContextError instead - // of the confusing "Invalid trace ID" from validateTraceId() (CLI-SC). - if (args.length === 1 && !first.includes("/")) { - const normalized = first.trim().toLowerCase().replace(/-/g, ""); - if (SPAN_ID_RE.test(normalized)) { - throw new ContextError("Trace ID and span ID", USAGE_HINT, [ - `'${first}' looks like a span ID (16 characters), not a trace ID`, - `Provide the trace ID first: sentry span view ${normalized}`, - `Use 'sentry trace list' to find trace IDs`, - ]); +function requireTraceId(traceArg: string, spanId: string): void { + try { + parseIdArguments([traceArg], ID_KINDS.trace, { + usageHint: USAGE_HINT, + needsProject: true, + validate: false, + }); + } catch (error) { + if (!(error instanceof ContextError)) { + throw error; } - } - - const rawSpanIds = args.slice(1); - if (rawSpanIds.length === 0) { - throw new ContextError("Span ID", USAGE_HINT, [ - `Use 'sentry span list ${first}' to find span IDs within this trace`, + throw new ContextError("Trace ID", USAGE_HINT, [ + `'${traceArg}' is missing a trace ID before the span ID '${spanId}'`, + `Provide the trace ID: sentry span view ${traceArg}/ ${spanId}`, ]); } - - return { kind: "deferred", rawTraceArg: first, rawSpanIds }; } /** @@ -464,16 +357,15 @@ export const viewCommand = buildCommand({ applyFreshFlag(flags); const { cwd } = this; - // Parse positional args. Either we get a resolved trace target (single-arg - // / slash form) or a deferred trace arg that routes through + // Parse positional args; the trace ID goes through // `parseTraceTargetWithRecovery` for async trace-ID recovery — same // contract as `trace view`. - const parsed = parsePositionalArgs(args); - const rawSpanIds = parsed.rawSpanIds; - const traceTarget = - parsed.kind === "resolved" - ? parsed.traceTarget - : await parseTraceTargetWithRecovery([parsed.rawTraceArg], USAGE_HINT); + const { traceArg, rawSpanIds } = parsePositionalArgs(args); + const traceTarget = await parseTraceTargetWithRecovery( + [traceArg], + USAGE_HINT, + { needsProject: true }, + ); warnIfNormalized(traceTarget, "span.view"); // Resolve org/project diff --git a/packages/cli/src/commands/trace/view.ts b/packages/cli/src/commands/trace/view.ts index 3aa370eff..42183d9c8 100644 --- a/packages/cli/src/commands/trace/view.ts +++ b/packages/cli/src/commands/trace/view.ts @@ -14,11 +14,7 @@ import { getProject, type TraceItemDetail, } from "../../lib/api-client.js"; -import { - detectSwappedViewArgs, - looksLikeIssueShortId, - spansFlag, -} from "../../lib/arg-parsing.js"; +import { looksLikeIssueShortId, spansFlag } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; import { parseOrgProjectTarget } from "../../lib/completion-target.js"; @@ -87,68 +83,29 @@ function buildViewHint( } /** - * Detect UX issues in raw positional args before trace-target parsing. + * Detect issue short IDs in raw positional args before trace-target parsing. + * Reversed arguments (` /`) need nothing here: the + * trace target's parser takes them, like every ID-taking command. * * - **Single-arg issue short ID**: first arg looks like `CAM-82X` with no * second arg → sets `issueShortId` for auto-recovery (resolve issue → trace). - * - **Swapped args**: user typed ` /` instead of - * `/ `. If detected, swaps them silently and warns. * - **Two-arg issue short ID**: first arg looks like `CAM-82X` with a second * arg → suggests `sentry issue view` (ambiguous intent, no auto-recovery). * - * Returns corrected args and optional warnings to emit. - * * @internal Exported for testing */ export function preProcessArgs(args: string[]): { - correctedArgs: string[]; - warning?: string; suggestion?: string; /** Issue short ID detected for auto-recovery (single-arg only) */ issueShortId?: string; } { - if (args.length === 0) { - return { correctedArgs: args }; + const [first] = args; + if (!(first && looksLikeIssueShortId(first))) { + return {}; } - - const first = args[0]; - if (!first) { - return { correctedArgs: args }; - } - - // Single-arg issue short ID → auto-recover by resolving issue → trace - if (args.length === 1 && looksLikeIssueShortId(first)) { - return { - correctedArgs: args, - issueShortId: first, - }; - } - - if (args.length < 2) { - return { correctedArgs: args }; - } - - const second = args[1]; - if (!second) { - return { correctedArgs: args }; - } - - // Detect swapped args: user put ID first and target second - const swapWarning = detectSwappedViewArgs(first, second); - if (swapWarning) { - // Swap them: put target first, trace ID second - return { - correctedArgs: [second, first, ...args.slice(2)], - warning: swapWarning, - }; - } - - // Detect issue short ID passed as first arg (two-arg case — ambiguous) - const suggestion = looksLikeIssueShortId(first) - ? `Did you mean: sentry issue view ${first}` - : undefined; - - return { correctedArgs: args, suggestion }; + return args.length === 1 + ? { issueShortId: first } + : { suggestion: `Did you mean: sentry issue view ${first}` }; } /** @@ -522,12 +479,8 @@ export const viewCommand = buildCommand({ const { cwd } = this; const log = logger.withTag("trace.view"); - // Pre-process: detect swapped args and issue short IDs - const { correctedArgs, warning, suggestion, issueShortId } = - preProcessArgs(args); - if (warning) { - log.warn(warning); - } + // Pre-process: detect issue short IDs + const { suggestion, issueShortId } = preProcessArgs(args); if (suggestion) { log.warn(suggestion); } @@ -536,10 +489,7 @@ export const viewCommand = buildCommand({ if (issueShortId) { resolved = await resolveTraceFromIssue(issueShortId, cwd); } else { - const parsed = await parseTraceTargetWithRecovery( - correctedArgs, - USAGE_HINT, - ); + const parsed = await parseTraceTargetWithRecovery(args, USAGE_HINT); warnIfNormalized(parsed, "trace.view"); const target = await resolveTraceOrgOptionalProject( parsed, diff --git a/packages/cli/src/lib/hex-id.ts b/packages/cli/src/lib/hex-id.ts index fd530028b..3047b418c 100644 --- a/packages/cli/src/lib/hex-id.ts +++ b/packages/cli/src/lib/hex-id.ts @@ -182,6 +182,13 @@ export function validateHexId(value: string, label: string): string { return normalized; } +/** Non-throwing check: whether {@link validateSpanId} accepts `value`. */ +export function isSpanId(value: string): boolean { + return SPAN_ID_RE.test( + value.trim().toLowerCase().replace(DASH_GLOBAL_RE, ""), + ); +} + /** * Validate that a string is a 16-character hexadecimal span ID. * Trims whitespace and normalizes to lowercase before validation. diff --git a/packages/cli/src/lib/id-arguments.ts b/packages/cli/src/lib/id-arguments.ts index e5ef5b0aa..6b3a9def4 100644 --- a/packages/cli/src/lib/id-arguments.ts +++ b/packages/cli/src/lib/id-arguments.ts @@ -1,126 +1,320 @@ /** - * Positionals that end in an ID, parsed by `grammar/id-arguments.pegjs`. + * Positionals that name IDs and the org/project to look them up in, parsed + * by `grammar/id-arguments.pegjs`. * - * What these arguments mean depends on how many were given: - * `[/[/]]` in one argument, ` ` in two, or a - * Sentry URL. The grammar sees every argument at once, decides which one is - * the ID and which is the target, and names the target's parts. Callers turn - * the result into their own target type (see `trace-target.ts` and - * `commands/replay/view.ts`). + * Every command that takes trace, span, replay, or log IDs shares one + * syntax, whatever the kind of ID: + * + * - ``: the org/project is auto-detected + * - `/` and `//`, like a URL (`/` + * for commands that need a project) + * - ` `, where `` is any org/project target + * - ` /`, reversed, with a warning + * - a Sentry URL with the ID in it + * + * and, for commands that take several, more IDs after any of those, each + * repeating the target or not. An argument may hold several lines, and IDs + * after a space: `"/ "` is two arguments. + * + * The command picks an {@link ID_KINDS} entry, which says what its IDs look + * like, how to validate them, and which part of a URL holds one. The grammar + * never guesses a kind from the shape of an ID: trace, replay, and log IDs + * all look alike. `sentry-cli/no-ad-hoc-id-arguments` keeps + * commands from parsing these forms on their own. * * Parsing has no side effects: a pasted URL's server comes back as `baseUrl` - * and ignored arguments as `warnings`, for the caller to apply. + * and anything to tell the user as `warnings`, for the caller to apply. */ import { parse } from "../generated/id-arguments-grammar.js"; import { ContextError, ValidationError } from "./errors.js"; -import { tryNormalizeHexId, validateHexId } from "./hex-id.js"; -import { parseSentryUrl } from "./sentry-url-parser.js"; - -/** What the arguments said to look the ID up in. */ -export type IdScope = - /** Nothing: `` or a URL without an org */ - | { readonly type: "none" } - /** `/` or a URL's org */ - | { readonly type: "org"; readonly org: string } - /** `//` */ - | { - readonly type: "org-project"; - readonly org: string; - readonly project: string; - } - /** ` `: a separate target argument, not parsed yet */ - | { readonly type: "target"; readonly target: string }; +import { + isSpanId, + tryNormalizeHexId, + validateHexId, + validateSpanId, +} from "./hex-id.js"; +import { type ParsedSentryUrl, parseSentryUrl } from "./sentry-url-parser.js"; -/** Arguments that end in an ID, parsed. */ -export type IdArguments = { - /** The normalized ID */ - readonly id: string; - readonly scope: IdScope; - /** Server of a pasted Sentry URL, for `applySentryUrlContext` */ - readonly baseUrl?: string; - /** Warnings to log, such as for ignored arguments */ - readonly warnings: readonly string[]; +/** A kind of ID a command takes. */ +export type IdKind = { + /** Name in messages, e.g. `"Trace ID"` */ + readonly label: string; + /** Whether `value` is an ID of this kind (after normalizing) */ + readonly isId: (value: string) => boolean; + /** + * Normalize an ID. + * + * @throws {ValidationError} When `value` is not one + */ + readonly validate: (value: string) => string; + /** The ID of this kind in a parsed Sentry URL, if it has one */ + readonly fromUrl: (url: ParsedSentryUrl) => string | undefined; + /** A URL with this kind of ID, suggested when a pasted URL has none */ + readonly urlExample?: string; }; -/** Arguments that list a project's items or, ending in a trace ID, a trace's. */ -export type TraceOrProjectArguments = - /** `target` is the first argument, if any */ - | { readonly mode: "project"; readonly target?: string } - | { readonly mode: "trace"; readonly trace: IdArguments }; +function hexKind( + label: string, + fromUrl: IdKind["fromUrl"], + urlExample?: string, +): IdKind { + const lower = label.charAt(0).toLowerCase() + label.slice(1); + return { + label, + isId: (value) => tryNormalizeHexId(value) !== undefined, + validate: (value) => validateHexId(value, lower), + fromUrl, + urlExample, + }; +} -/** The grammar's start rules that parse {@link IdArguments}. */ -type IdArgumentsRule = "trace" | "tracePath" | "replay"; +/** The kinds of ID commands take. Commands use these instead of their own. */ +export const ID_KINDS = { + trace: hexKind("Trace ID", (url) => url.traceId), + // Replays are looked up in an org, so a replay URL needs one + replay: hexKind( + "Replay ID", + (url) => (url.org ? url.replayId : undefined), + "https://sentry.io/organizations/{org}/explore/replays/{replayId}/", + ), + log: hexKind("Log ID", () => undefined), + span: { + label: "Span ID", + isId: isSpanId, + validate: validateSpanId, + fromUrl: () => undefined, + }, +} as const satisfies Record; -/** What an ID is called, and how the command is used, for errors. */ -type IdArgumentsContext = { - /** e.g. `"Trace ID"` */ - readonly idLabel: string; +/** How a command takes its IDs. */ +export type IdArgumentsOptions = { + /** Usage shown in errors and warnings */ readonly usageHint: string; + /** Whether the command takes more than one ID */ + readonly multiple?: boolean; + /** + * Whether the command may get no ID, like a list of a project's items or + * a trace's. Then a lone argument that does not end in an ID is a target. + */ + readonly optional?: boolean; + /** + * Whether the command needs a project, not just an org. Then `/` + * names project `` (found in any org), like ` ` does. + */ + readonly needsProject?: boolean; + /** + * Whether to validate the IDs. Commands that recover malformed IDs once + * they know the target turn it off, and validate later. + */ + readonly validate?: boolean; +}; + +/** ID arguments, parsed. */ +export type IdArguments = { + /** + * The org/project target argument, for `parseOrgProjectInput`; + * `undefined` to auto-detect. `/` gives `/` (the org), or `` + * when the command needs a project. + */ + readonly target?: string; + /** The IDs: normalized, or as typed when not validated */ + readonly ids: readonly string[]; + /** Server of a pasted Sentry URL, for `applySentryUrlContext` */ + readonly baseUrl?: string; + /** Warnings to log, such as for ignored or reversed arguments */ + readonly warnings: readonly string[]; }; /** - * Parse arguments that end in an ID. + * Parse ID arguments. * - * @param rule - `trace` or `replay` for the arguments of those commands, - * `tracePath` for one `[/[/]]` argument - * @throws {ContextError} When the ID is missing - * @throws {ValidationError} When the ID is invalid or there are too many - * arguments + * @throws {ContextError} When the IDs are missing and not optional + * @throws {ValidationError} When an ID is invalid (unless not validated), a + * path has an empty org or project, or IDs name different targets */ export function parseIdArguments( args: readonly string[], - rule: IdArgumentsRule, - context: IdArgumentsContext, + kind: IdKind, + options: IdArgumentsOptions, ): IdArguments { - return parse(grammarInput(args), { ...hooks(context), startRule: rule }); + const { + usageHint, + multiple = false, + optional = false, + needsProject = false, + } = options; + const tokens = tokenize(args, kind.isId); + const syntax = parse(tokens.map((token) => `\0${token}`).join(""), { + optional, + isId: kind.isId, + idUrl: (value) => idUrl(value, kind), + }); + const warnings: string[] = []; + + const pathTarget = (prefix: string, text: string) => + pathTargetArg(prefix, text, { usageHint, needsProject }); + let target = syntax.path + ? pathTarget(syntax.target ?? "", syntax.ids[0]?.text ?? "") + : syntax.target; + const typed: { text: string; id: string }[] = []; + for (const token of syntax.ids) { + if (token.target) { + const own = pathTarget(token.target, token.text); + if (target === undefined) { + target = own; + } else if (own !== target && token.target !== target) { + throw new ValidationError( + `${kind.label}s reference different projects ('${target}' and '${own}'). ` + + `Pass IDs from one project per call: ${usageHint}`, + "positional", + ); + } + } + if (token.id !== "") { + typed.push(token); + } + } + + if (syntax.reversed && target !== undefined && typed[0]) { + warnings.push( + `Arguments appear reversed. Interpreting as: ${target} ${typed[0].text}`, + ); + } + + if (typed.length === 0) { + if (optional) { + return { target, ids: [], baseUrl: syntax.baseUrl, warnings }; + } + throw new ContextError( + kind.label, + usageHint, + missingSuggestions(tokens, kind), + ); + } + + let chosen = typed; + if (!multiple && typed.length > 1) { + // Keep the first real ID; the rest are extra + const first = typed.find((token) => kind.isId(token.id)) ?? typed[0]; + chosen = first ? [first] : []; + const extra = typed.filter((token) => token !== first); + warnings.push( + `Extra arguments ignored: ${extra.map((token) => token.text).join(" ")}. Expected: ${usageHint}`, + ); + } + + const validate = options.validate ?? true; + return { + target, + ids: chosen.map((token) => (validate ? kind.validate(token.id) : token.id)), + baseUrl: syntax.baseUrl, + warnings, + }; } +/** Arguments that name exactly one ID, parsed. */ +export type IdArgument = Omit & { + /** The ID: normalized, or as typed when not validated */ + readonly id: string; +}; + /** - * Parse the arguments of a command that lists a project's items or a - * trace's: trace mode when they are a trace URL, end in a trace ID, or start - * with `[/[/]]`. + * Parse arguments that name one ID, like {@link parseIdArguments} for a + * command that takes neither several IDs nor none. */ -export function parseTraceOrProjectArguments( +export function parseIdArgument( args: readonly string[], - usageHint: string, -): TraceOrProjectArguments { - return parse(grammarInput(args), { - ...hooks({ idLabel: "Trace ID", usageHint }), - startRule: "traceOrProject", - }); + kind: IdKind, + options: Omit, +): IdArgument { + const { ids, ...rest } = parseIdArguments(args, kind, options); + const [id] = ids; + if (id === undefined) { + throw new ContextError(kind.label, options.usageHint); + } + return { ...rest, id }; } +/** Line breaks between IDs pasted into one argument */ +const LINE_BREAK_RE = /\r?\n/; +const WHITESPACE_RE = /\s+/; + /** - * The grammar's input: every argument, trimmed, after a NUL. A command line - * cannot contain NUL, so the grammar can always tell the arguments apart. + * The grammar's tokens: every line of every argument, trimmed, and with the + * IDs at the end of a line split off at spaces. A line's first word stays + * whole, so a project's display name keeps its spaces. Empty lines are + * dropped: an empty target means none. */ -function grammarInput(args: readonly string[]): string { - return args - .map((arg) => { - if (arg.includes("\0")) { - throw new ValidationError( - "Arguments cannot contain NUL characters.", - "positional", - ); +function tokenize( + args: readonly string[], + isId: (value: string) => boolean, +): string[] { + const tokens: string[] = []; + for (const arg of args) { + if (arg.includes("\0")) { + throw new ValidationError( + "Arguments cannot contain NUL characters.", + "positional", + ); + } + for (const line of arg.split(LINE_BREAK_RE)) { + const words = line.trim().split(WHITESPACE_RE); + const ids: string[] = []; + while (words.length > 1 && isId(words.at(-1) ?? "")) { + ids.unshift(words.pop() ?? ""); } - return `\0${arg.trim()}`; - }) - .join(""); + tokens.push(...[words.join(" "), ...ids].filter((token) => token)); + } + } + return tokens; } -function hooks({ idLabel, usageHint }: IdArgumentsContext) { - const label = idLabel.charAt(0).toLowerCase() + idLabel.slice(1); +/** + * The target argument for the `[/[/]]` before an ID in a path + * (`text`): `/` with both; with one, `/`, or the project + * when the command needs one. + * + * @throws {ValidationError} When the org or the project is empty + */ +function pathTargetArg( + prefix: string, + text: string, + options: { usageHint: string; needsProject: boolean }, +): string { + const { usageHint, needsProject } = options; + const slash = prefix.indexOf("/"); + if (slash === -1) { + return needsProject ? prefix : `${prefix}/`; + } + if (slash === 0 || slash === prefix.length - 1) { + throw new ValidationError( + `Invalid format: "${text}" needs an organization and a project before the ID.\n\nUsage: ${usageHint}`, + "positional", + ); + } + return prefix; +} + +/** The ID of `kind` in a Sentry URL, or `null` for anything else. */ +function idUrl(value: string, kind: IdKind) { + const url = parseSentryUrl(value); + const id = url ? kind.fromUrl(url) : undefined; + if (!(url && id)) { + return null; + } return { - usageHint, - isId: (value: string) => tryNormalizeHexId(value) !== undefined, - id: (value: string) => validateHexId(value, label), - sentryUrl: parseSentryUrl, - missing: (suggestions: string[] = []): never => { - throw new ContextError(idLabel, usageHint, suggestions); - }, - invalid: (message: string, field?: string): never => { - throw new ValidationError(message, field); - }, + id, + ...(url.org && { org: url.org }), + baseUrl: url.baseUrl, }; } + +function missingSuggestions(tokens: readonly string[], kind: IdKind): string[] { + const pastedUrl = tokens.some((token) => parseSentryUrl(token) !== null); + return pastedUrl && kind.urlExample + ? [ + `Pass a ${kind.label.replace(" ID", "").toLowerCase()} URL: ${kind.urlExample}`, + ] + : []; +} diff --git a/packages/cli/src/lib/trace-target.ts b/packages/cli/src/lib/trace-target.ts index 6cdb82798..a9c2c70d4 100644 --- a/packages/cli/src/lib/trace-target.ts +++ b/packages/cli/src/lib/trace-target.ts @@ -1,19 +1,13 @@ /** * Shared Trace-Target Parsing & Resolution * - * Provides a unified abstraction for commands that accept trace IDs - * with optional org/project context. Trace IDs are globally unique, - * so these formats are all supported: + * Trace commands take their trace ID like every ID-taking command (see + * `id-arguments.ts`): ``, `/`, + * `//`, ` `, or a trace URL. + * This module turns that into a {@link ParsedTraceTarget} and resolves it + * to the org (and project) the command's API needs. * - * - `` — auto-detect org/project from DSN/config - * - `/` — org-scoped (for org-only APIs like trace-logs) - * - `//` — fully explicit - * - * Also handles two-arg forms: - * - `/ ` — target as first arg, trace ID as second - * - ` ` — project-first bare target with org fallback - * - * Used by: span list, span view, trace view, trace logs. + * Used by: span list, span view, trace view, trace logs, log list. */ import { @@ -28,9 +22,10 @@ import { resolveRecoveryOrg, } from "./hex-id-recovery.js"; import { + ID_KINDS, type IdArguments, + parseIdArgument, parseIdArguments, - parseTraceOrProjectArguments, } from "./id-arguments.js"; import { logger } from "./logger.js"; import { @@ -43,7 +38,6 @@ import { } from "./resolve-target.js"; import { applySentryUrlContext } from "./sentry-url-parser.js"; import { setOrgProjectContext } from "./telemetry.js"; -import { isTraceId } from "./trace-id.js"; const log = logger.withTag("trace-target"); @@ -139,19 +133,10 @@ export type ResolvedTraceOrg = { /** * Parse trace-related positional arguments into a {@link ParsedTraceTarget}. * - * **Single argument (slash-separated):** - * - `` → auto-detect - * - `/` → org-scoped - * - `//` → explicit - * - * **Two arguments (space-separated):** - * - `/ ` → explicit - * - ` ` → project-first bare target with org fallback - * - * A Sentry trace URL works too. Extra positional arguments beyond the first - * two are ignored with a warning, matching the established pattern across - * CLI commands. The `trace` rule of `grammar/id-arguments.pegjs` defines the - * forms. + * Takes the forms every ID-taking command does (see `id-arguments.ts`): + * ``, `/`, `//`, + * ` `, ` `, or a trace URL. Extra + * arguments are ignored with a warning. * * @param args - Positional arguments from CLI * @param usageHint - Usage example for error messages @@ -160,12 +145,22 @@ export type ResolvedTraceOrg = { * @throws {ValidationError} If the trace ID format is invalid */ export function parseTraceTarget( - args: string[], + args: readonly string[], usageHint: string, + options: TraceArgumentsOptions = {}, ): ParsedTraceTarget { - return applyTraceTargetInput(parseTraceTargetInput(args, usageHint)); + return applyTraceTargetInput(parseTraceTargetInput(args, usageHint, options)); } +/** How a trace command takes its trace ID. */ +export type TraceArgumentsOptions = { + /** + * Whether the command needs a project (`span list`, `span view`): then + * `/` names project `` instead of org ``. + */ + readonly needsProject?: boolean; +}; + /** * Parse trace-related positional arguments like {@link parseTraceTarget}, * without side effects: the server of a pasted Sentry URL and the warnings @@ -174,131 +169,32 @@ export function parseTraceTarget( export function parseTraceTargetInput( args: readonly string[], usageHint: string, + options: TraceArgumentsOptions = {}, ): TraceTargetInput { - return traceTargetInput( - parseIdArguments(args, "trace", { idLabel: "Trace ID", usageHint }), - ); + const { id, ...rest } = parseIdArgument(args, ID_KINDS.trace, { + usageHint, + ...options, + }); + return traceTargetInput({ ...rest, ids: [id] }); } /** - * Turn the grammar's {@link IdArguments} into a trace target. A separate - * target argument parses like any org/project target. + * Turn {@link IdArguments} with one trace ID into a trace target. The target + * parses like any org/project target. */ function traceTargetInput({ - id: traceId, - scope, + target, + ids, baseUrl, warnings, }: IdArguments): TraceTargetInput { - switch (scope.type) { - case "none": - return { parsed: { type: "auto-detect", traceId }, baseUrl, warnings }; - case "org": - return { - parsed: { type: "org-scoped", traceId, org: scope.org }, - baseUrl, - warnings, - }; - case "org-project": - return { - parsed: { - type: "explicit", - traceId, - org: scope.org, - project: scope.project, - }, - baseUrl, - warnings, - }; - case "target": - return { - ...targetArgToTraceTargetInput(scope.target, traceId), - warnings, - }; - default: { - const _exhaustive: never = scope; - throw new ValidationError(`Unexpected trace scope: ${_exhaustive}`); - } - } -} - -/** - * Remove trailing `/` characters when the segment before them is a trace ID, - * so `org/project//` splits the same as `org/project/`. - * - * Inputs like `org/project/` are left untouched: the trailing slash there - * means the trace ID was omitted, which must stay a "Trace ID is required" - * error rather than being misread as `org/`. - * - * @internal Exported for testing - */ -export function stripTrailingSlashesAfterTraceId(input: string): string { - let end = input.length; - while (end > 0 && input[end - 1] === "/") { - end -= 1; - } - if (end === input.length) { - return input; - } - const stripped = input.slice(0, end); - const tail = stripped.slice(stripped.lastIndexOf("/") + 1); - return isTraceId(tail) ? stripped : input; -} - -/** - * Parse a single slash-separated argument into a trace target. - * - * - No slashes → auto-detect (bare trace ID) - * - One slash → `/` (org-scoped) - * - Two+ slashes → split on last `/` → `//` (explicit) - * - * The `tracePath` rule of `grammar/id-arguments.pegjs` defines the forms. - * - * @internal Exported for testing - */ -export function parseSlashSeparatedTraceTarget( - rawInput: string, - usageHint: string, -): ParsedTraceTarget { - return traceTargetInput( - parseIdArguments([rawInput], "tracePath", { - idLabel: "Trace ID", - usageHint, - }), - ).parsed; -} - -/** - * Convert a target argument + validated trace ID into a trace target. - * Delegates org/project parsing to {@link parseOrgProjectArg}. - * - * Note: `parseOrgProjectArg` already emits slug normalization warnings - * internally, so `normalized` is NOT propagated here to avoid double - * warnings when callers also check via {@link warnIfNormalized}. - * - * @internal Exported for testing - */ -export function targetArgToTraceTarget( - targetArg: string, - traceId: string, -): ParsedTraceTarget { - const { parsed, baseUrl } = targetArgToTraceTargetInput(targetArg, traceId); - if (baseUrl) { - applySentryUrlContext(baseUrl); - } - return parsed; -} - -/** - * {@link targetArgToTraceTarget} without side effects: returns the server of - * a pasted Sentry URL instead of applying it. - */ -function targetArgToTraceTargetInput( - targetArg: string, - traceId: string, -): { parsed: ParsedTraceTarget; baseUrl?: string } { - const { parsed, baseUrl } = parseOrgProjectInput(targetArg); - return { parsed: orgProjectToTraceTarget(parsed, traceId), baseUrl }; + const traceId = ids[0] ?? ""; + const input = parseOrgProjectInput(target); + return { + parsed: orgProjectToTraceTarget(input.parsed, traceId), + baseUrl: baseUrl ?? input.baseUrl, + warnings, + }; } function orgProjectToTraceTarget( @@ -343,48 +239,6 @@ function orgProjectToTraceTarget( } } -/** - * Extract the raw trace ID slot from a positional-arg invocation **without - * validating** it. Returns `{ rawTraceId, targetArg? }` where: - * - * - Single arg → the part after the last `/` is the trace ID candidate, - * and anything before the last `/` is the target (org/project[/]). - * - Two+ args → the second arg is the raw trace ID, the first is the target. - * - * Used by {@link parseTraceTargetWithRecovery} to peek at the raw input - * before `parseTraceTarget` runs its strict validation. Returns null when - * there isn't a usable trace ID slot (e.g., zero args). - */ -export function extractRawTraceId( - args: string[], -): { rawTraceId: string; targetArg?: string } | null { - if (args.length === 0) { - return null; - } - if (args.length === 1) { - const rawFirst = args[0]; - if (!rawFirst) { - return null; - } - const first = stripTrailingSlashesAfterTraceId(rawFirst); - const lastSlash = first.lastIndexOf("/"); - if (lastSlash === -1) { - return { rawTraceId: first }; - } - const rawTraceId = first.slice(lastSlash + 1); - const prefix = first.slice(0, lastSlash); - return rawTraceId - ? { rawTraceId, targetArg: prefix || undefined } - : { rawTraceId: "" }; - } - const second = args[1]; - const first = args[0]; - if (second === undefined) { - return null; - } - return { rawTraceId: second, targetArg: first }; -} - /** * Parse trace-target args, falling back to {@link recoverHexId} on an * invalid trace ID. Returns a {@link ParsedTraceTarget} with a validated @@ -398,31 +252,68 @@ export function extractRawTraceId( * and the adapter returns empty without a project anyway). */ export async function parseTraceTargetWithRecovery( - args: string[], + args: readonly string[], usageHint: string, + options: TraceArgumentsOptions = {}, ): Promise { try { - return parseTraceTarget(args, usageHint); + return parseTraceTarget(args, usageHint, options); } catch (err) { if (!(err instanceof ValidationError)) { throw err; } - const raw = extractRawTraceId(args); - if (!raw?.rawTraceId) { + const raw = rawIdArguments(args, { usageHint, ...options }, err); + const rawTraceId = raw.ids[0]; + if (!rawTraceId) { throw err; } - const ctx = await recoveryContextFromTargetArg(raw.targetArg); - const result = await recoverHexId(raw.rawTraceId, "trace", ctx); - const recoveredTraceId = handleRecoveryResult(result, err, { - entityType: "trace", - canonicalCommand: `sentry trace view ${ctx.org || ""}/${ctx.project ?? ""}/`, - logTag: "trace-target", + const recovered = await recoverTraceId(rawTraceId, raw.target, err); + return applyTraceTargetInput( + traceTargetInput({ ...raw, ids: [recovered] }), + ); + } +} + +/** + * The trace ID arguments as typed, to recover the ID from; `failure` when + * even they do not parse (an invalid path, say). + */ +function rawIdArguments( + args: readonly string[], + options: TraceArgumentsOptions & { readonly usageHint: string }, + failure: ValidationError, +): IdArguments { + try { + return parseIdArguments(args, ID_KINDS.trace, { + ...options, + validate: false, }); - const newArgs = substituteTraceId(args, recoveredTraceId); - return parseTraceTarget(newArgs, usageHint); + } catch (error) { + log.debug("Trace arguments do not parse without validation", error); + throw failure; } } +/** + * Recover a malformed trace ID with {@link recoverHexId}, in the context of + * the target argument, if any. + * + * @throws The original `failure` when recovery finds nothing + */ +async function recoverTraceId( + rawTraceId: string, + targetArg: string | undefined, + failure: ValidationError, +): Promise { + const ctx = await recoveryContextFromTargetArg(targetArg); + const result = await recoverHexId(rawTraceId, "trace", ctx); + return handleRecoveryResult(result, failure, { + entityType: "trace", + canonicalCommand: `sentry trace view ${ctx.org || ""}/${ctx.project ?? ""}/`, + logTag: "trace-target", + }); +} + /** * Best-effort recovery context derived from a raw target arg. * @@ -453,26 +344,6 @@ async function recoveryContextFromTargetArg( ); } -/** - * Rebuild the positional args with the recovered trace ID in the correct - * slot. Mirrors the logic in {@link extractRawTraceId}. - */ -function substituteTraceId(args: string[], recoveredTraceId: string): string[] { - if (args.length === 1) { - const first = args[0]; - if (!first) { - return [recoveredTraceId]; - } - const lastSlash = first.lastIndexOf("/"); - if (lastSlash === -1) { - return [recoveredTraceId]; - } - const prefix = first.slice(0, lastSlash); - return [`${prefix}/${recoveredTraceId}`]; - } - return [args[0] ?? "", recoveredTraceId, ...args.slice(2)]; -} - /** * Emit a slug normalization warning if applicable. * Shared by all trace commands to avoid duplicating the check. @@ -675,21 +546,20 @@ export type ParsedDualModeArgs = /** * Disambiguate positional arguments for dual-mode list commands. * - * Trace mode when the arguments are a trace URL, end in a trace ID - * (` `), or start with `[/[/]]`; - * arguments the trace target does not use are ignored with a warning. - * Otherwise project mode, with the first argument as the target. The - * `traceOrProject` rule of `grammar/id-arguments.pegjs` defines the forms. + * Trace mode when the arguments name a trace ID in any of the forms + * {@link parseTraceTarget} takes; otherwise project mode, with the first + * argument as the target. * * @param args - Positional arguments from CLI * @param traceUsageHint - Usage hint for trace-mode error messages * @returns Parsed args with mode discrimination */ export function parseDualModeArgs( - args: string[], + args: readonly string[], traceUsageHint: string, + options: TraceArgumentsOptions = {}, ): ParsedDualModeArgs { - const input = parseDualModeInput(args, traceUsageHint); + const input = parseDualModeInput(args, traceUsageHint, options); return input.mode === "trace" ? { mode: "trace", parsed: applyTraceTargetInput(input.trace) } : input; @@ -708,9 +578,17 @@ export type DualModeInput = export function parseDualModeInput( args: readonly string[], traceUsageHint: string, + options: TraceArgumentsOptions = {}, ): DualModeInput { - const input = parseTraceOrProjectArguments(args, traceUsageHint); - return input.mode === "trace" - ? { mode: "trace", trace: traceTargetInput(input.trace) } - : input; + const input = parseIdArguments(args, ID_KINDS.trace, { + ...options, + usageHint: traceUsageHint, + optional: true, + }); + if (input.ids.length > 0) { + return { mode: "trace", trace: traceTargetInput(input) }; + } + return input.target === undefined + ? { mode: "project" } + : { mode: "project", target: input.target }; } diff --git a/packages/cli/test/commands/log/view.test.ts b/packages/cli/test/commands/log/view.test.ts index be524cbc5..502c34aae 100644 --- a/packages/cli/test/commands/log/view.test.ts +++ b/packages/cli/test/commands/log/view.test.ts @@ -279,7 +279,7 @@ describe("parsePositionalArgs", () => { // Auto-swap: org/project becomes target, hex ID becomes the log ID expect(result.targetArg).toBe("my-org/my-project"); expect(result.rawLogIds).toEqual(["968c763c740cfda8b6728f27fb9e9b01"]); - expect(result.suggestion).toContain("reversed"); + expect(result.warnings).toEqual([expect.stringContaining("reversed")]); }); test("returns suggestion when first arg looks like issue short ID", () => { diff --git a/packages/cli/test/commands/replay/view.test.ts b/packages/cli/test/commands/replay/view.test.ts index ae391d0bb..dc7c87db5 100644 --- a/packages/cli/test/commands/replay/view.test.ts +++ b/packages/cli/test/commands/replay/view.test.ts @@ -186,10 +186,14 @@ describe("parseReplayArguments", () => { ); }); - test("throws ValidationError for extra positional args", () => { - expect(() => + test("ignores extra positional args with a warning", () => { + expect( parseReplayArguments(["test-org/cli", REPLAY_ID, "extra-arg"]), - ).toThrow(ValidationError); + ).toMatchObject({ + replayId: REPLAY_ID, + target: { type: "explicit", org: "test-org", project: "cli" }, + warnings: [expect.stringContaining("Extra arguments ignored: extra-arg")], + }); }); }); diff --git a/packages/cli/test/commands/span/view.test.ts b/packages/cli/test/commands/span/view.test.ts index f36d7d7bb..3ad4928c8 100644 --- a/packages/cli/test/commands/span/view.test.ts +++ b/packages/cli/test/commands/span/view.test.ts @@ -93,266 +93,117 @@ describe("validateSpanId", () => { }); describe("parsePositionalArgs", () => { - describe("trace-id + single span-id", () => { - test("returns deferred form for two-arg input (trace-ID recovery runs in command)", () => { - const result = parsePositionalArgs([VALID_TRACE_ID, VALID_SPAN_ID]); - expect(result.kind).toBe("deferred"); - if (result.kind !== "deferred") throw new Error("unreachable"); - expect(result.rawTraceArg).toBe(VALID_TRACE_ID); - expect(result.rawSpanIds).toEqual([VALID_SPAN_ID]); - }); - }); - - describe("trace-id + multiple span-ids", () => { - test("passes multiple span IDs through as raw", () => { - const result = parsePositionalArgs([ - VALID_TRACE_ID, - VALID_SPAN_ID, - VALID_SPAN_ID_2, - ]); - expect(result.kind).toBe("deferred"); - if (result.kind !== "deferred") throw new Error("unreachable"); - expect(result.rawTraceArg).toBe(VALID_TRACE_ID); - expect(result.rawSpanIds).toEqual([VALID_SPAN_ID, VALID_SPAN_ID_2]); - }); - }); - - describe("org/project/trace-id + span-id", () => { - test("slash-separated target is deferred intact", () => { - const slashForm = `my-org/my-project/${VALID_TRACE_ID}`; - const result = parsePositionalArgs([slashForm, VALID_SPAN_ID]); - expect(result.kind).toBe("deferred"); - if (result.kind !== "deferred") throw new Error("unreachable"); - expect(result.rawTraceArg).toBe(slashForm); - expect(result.rawSpanIds).toEqual([VALID_SPAN_ID]); - }); + const SLASH_FORM = `my-org/my-project/${VALID_TRACE_ID}`; - test("slash-separated target with multiple span IDs", () => { - const slashForm = `my-org/my-project/${VALID_TRACE_ID}`; - const result = parsePositionalArgs([ - slashForm, - VALID_SPAN_ID, - VALID_SPAN_ID_2, - ]); - expect(result.kind).toBe("deferred"); - if (result.kind !== "deferred") throw new Error("unreachable"); - expect(result.rawTraceArg).toBe(slashForm); - expect(result.rawSpanIds).toEqual([VALID_SPAN_ID, VALID_SPAN_ID_2]); - }); + test.each([ + [[VALID_TRACE_ID, VALID_SPAN_ID], VALID_TRACE_ID, [VALID_SPAN_ID]], + [ + [VALID_TRACE_ID, VALID_SPAN_ID, VALID_SPAN_ID_2], + VALID_TRACE_ID, + [VALID_SPAN_ID, VALID_SPAN_ID_2], + ], + [[SLASH_FORM, VALID_SPAN_ID], SLASH_FORM, [VALID_SPAN_ID]], + [ + [SLASH_FORM, VALID_SPAN_ID, VALID_SPAN_ID_2], + SLASH_FORM, + [VALID_SPAN_ID, VALID_SPAN_ID_2], + ], + [ + [`my-org/${VALID_TRACE_ID}`, VALID_SPAN_ID], + `my-org/${VALID_TRACE_ID}`, + [VALID_SPAN_ID], + ], + // `/`, as span list prints it + [[`${VALID_TRACE_ID}/${VALID_SPAN_ID}`], VALID_TRACE_ID, [VALID_SPAN_ID]], + [ + [`${SLASH_FORM}/${VALID_SPAN_ID}`, VALID_SPAN_ID_2], + SLASH_FORM, + [VALID_SPAN_ID, VALID_SPAN_ID_2], + ], + // Several IDs in one argument (CLI-1BD) + [[`${VALID_TRACE_ID} ${VALID_SPAN_ID}`], VALID_TRACE_ID, [VALID_SPAN_ID]], + [[`${SLASH_FORM} ${VALID_SPAN_ID}`], SLASH_FORM, [VALID_SPAN_ID]], + [ + [`${VALID_TRACE_ID}\n${VALID_SPAN_ID}\n${VALID_SPAN_ID_2}\n`], + VALID_TRACE_ID, + [VALID_SPAN_ID, VALID_SPAN_ID_2], + ], + [ + [`${VALID_TRACE_ID}/${VALID_SPAN_ID}\n${VALID_SPAN_ID_2}`], + VALID_TRACE_ID, + [VALID_SPAN_ID, VALID_SPAN_ID_2], + ], + ])("%j → trace %j, spans %j", (args, traceArg, rawSpanIds) => { + expect(parsePositionalArgs(args)).toEqual({ traceArg, rawSpanIds }); }); - describe("auto-split traceId/spanId single-arg format", () => { - test("auto-splits traceId/spanId single-arg format (resolved path)", () => { - const result = parsePositionalArgs([ - "aaaa1111bbbb2222cccc3333dddd4444/a1b2c3d4e5f67890", - ]); - expect(result.kind).toBe("resolved"); - if (result.kind !== "resolved") throw new Error("unreachable"); - expect(result.traceTarget.traceId).toBe( - "aaaa1111bbbb2222cccc3333dddd4444", - ); - expect(result.traceTarget.type).toBe("auto-detect"); - expect(result.rawSpanIds).toEqual(["a1b2c3d4e5f67890"]); - }); - - test("auto-splits with uppercase hex IDs", () => { - const result = parsePositionalArgs([ + test("keeps the case of IDs for the command to normalize", () => { + expect( + parsePositionalArgs([ "AAAA1111BBBB2222CCCC3333DDDD4444/A1B2C3D4E5F67890", - ]); - expect(result.kind).toBe("resolved"); - if (result.kind !== "resolved") throw new Error("unreachable"); - expect(result.traceTarget.traceId).toBe( - "aaaa1111bbbb2222cccc3333dddd4444", - ); - expect(result.rawSpanIds).toEqual(["a1b2c3d4e5f67890"]); - }); - - test("does not auto-split org/traceId format (two args) — defers to command", () => { - // org/traceId has a non-hex org slug, so it shouldn't trigger the auto-split - const result = parsePositionalArgs([ - "my-org/aaaa1111bbbb2222cccc3333dddd4444", - "a1b2c3d4e5f67890", - ]); - expect(result.kind).toBe("deferred"); - if (result.kind !== "deferred") throw new Error("unreachable"); - expect(result.rawTraceArg).toBe( - "my-org/aaaa1111bbbb2222cccc3333dddd4444", - ); - expect(result.rawSpanIds).toEqual(["a1b2c3d4e5f67890"]); - }); - - test("does not auto-split when left is not a valid trace ID", () => { - // Auto-split requires hex-on-both-sides. When left is non-hex, - // the single-arg input falls through to the "missing span IDs" - // branch and throws ContextError. (A two-arg variant would defer - // to the command layer — covered by other tests.) - expect(() => - parsePositionalArgs(["not-a-hex-id/a1b2c3d4e5f67890"]), - ).toThrow(ContextError); - }); - - test("does not auto-split when right is not a valid span ID", () => { - // Right side 32-char hex is a trace ID (not a span ID) so the - // auto-split heuristic rejects it. As a single-arg form there are - // no span IDs in a second positional → ContextError. - expect(() => - parsePositionalArgs([ - "aaaa1111bbbb2222cccc3333dddd4444/bbbb2222cccc3333dddd4444eeee5555", - ]), - ).toThrow(ContextError); - }); - - test("does not auto-split with multiple slashes (defers to command)", () => { - // org/project/traceId format — deferred to the command layer - // which calls parseTraceTargetWithRecovery. - const slashForm = `my-org/my-project/${VALID_TRACE_ID}`; - const result = parsePositionalArgs([slashForm, VALID_SPAN_ID]); - expect(result.kind).toBe("deferred"); - if (result.kind !== "deferred") throw new Error("unreachable"); - expect(result.rawTraceArg).toBe(slashForm); - }); - - test("org/project/spanId (missing trace ID) throws a trace-focused ContextError (CLI-1GP)", () => { - try { - parsePositionalArgs([`my-org/my-project/${VALID_SPAN_ID}`]); - expect.unreachable("Should have thrown"); - } catch (error) { - expect(error).toBeInstanceOf(ContextError); - const msg = (error as ContextError).message; - // Points at the missing trace ID, not a missing span ID. - expect(msg).toContain("trace ID"); - expect(msg).toContain(VALID_SPAN_ID); - // Does not misdirect the user to `span list` with the span ID. - expect(msg).not.toContain( - `span list my-org/my-project/${VALID_SPAN_ID}`, - ); - } - }); - - test("org/project/badTrace/spanId surfaces the trace validation detail", () => { - try { - parsePositionalArgs([`my-org/my-project/not-a-trace/${VALID_SPAN_ID}`]); - expect.unreachable("Should have thrown"); - } catch (error) { - expect(error).toBeInstanceOf(ContextError); - const msg = (error as ContextError).message; - expect(msg).toContain("trace ID"); - } + ]), + ).toEqual({ + traceArg: "AAAA1111BBBB2222CCCC3333DDDD4444", + rawSpanIds: ["A1B2C3D4E5F67890"], }); }); - describe("whitespace-separated single-arg format (CLI-1BD)", () => { - test("splits ' ' passed as one argument", () => { - const result = parsePositionalArgs([ - `${VALID_TRACE_ID} ${VALID_SPAN_ID}`, - ]); - expect(result.kind).toBe("deferred"); - if (result.kind !== "deferred") throw new Error("unreachable"); - expect(result.rawTraceArg).toBe(VALID_TRACE_ID); - expect(result.rawSpanIds).toEqual([VALID_SPAN_ID]); + test("leaves malformed IDs raw, for recovery at the command layer", () => { + expect(parsePositionalArgs(["not-valid", VALID_SPAN_ID])).toEqual({ + traceArg: "not-valid", + rawSpanIds: [VALID_SPAN_ID], }); - - test("splits 'org/project/ ' passed as one argument", () => { - const slashForm = `my-org/my-project/${VALID_TRACE_ID}`; - const result = parsePositionalArgs([`${slashForm} ${VALID_SPAN_ID}`]); - expect(result.kind).toBe("deferred"); - if (result.kind !== "deferred") throw new Error("unreachable"); - expect(result.rawTraceArg).toBe(slashForm); - expect(result.rawSpanIds).toEqual([VALID_SPAN_ID]); - }); - - test("splits newline-separated span IDs after the trace ID", () => { - const result = parsePositionalArgs([ - `${VALID_TRACE_ID}\n${VALID_SPAN_ID}\n${VALID_SPAN_ID_2}\n`, - ]); - expect(result.kind).toBe("deferred"); - if (result.kind !== "deferred") throw new Error("unreachable"); - expect(result.rawTraceArg).toBe(VALID_TRACE_ID); - expect(result.rawSpanIds).toEqual([VALID_SPAN_ID, VALID_SPAN_ID_2]); - }); - - test("handles '/' followed by newline-separated span IDs", () => { - const result = parsePositionalArgs([ - `${VALID_TRACE_ID}/${VALID_SPAN_ID}\n${VALID_SPAN_ID_2}`, - ]); - expect(result.kind).toBe("resolved"); - if (result.kind !== "resolved") throw new Error("unreachable"); - expect(result.traceTarget.traceId).toBe(VALID_TRACE_ID); - expect(result.rawSpanIds).toEqual([VALID_SPAN_ID, VALID_SPAN_ID_2]); + expect(parsePositionalArgs([`not-a-hex-id/${VALID_SPAN_ID}`])).toEqual({ + traceArg: "not-a-hex-id", + rawSpanIds: [VALID_SPAN_ID], }); - - test("handles 'org/project//' plus extra span ID args", () => { - const result = parsePositionalArgs([ - `my-org/my-project/${VALID_TRACE_ID}/${VALID_SPAN_ID}`, - VALID_SPAN_ID_2, - ]); - expect(result.kind).toBe("resolved"); - if (result.kind !== "resolved") throw new Error("unreachable"); - expect(result.traceTarget.type).toBe("explicit"); - expect(result.traceTarget.traceId).toBe(VALID_TRACE_ID); - expect(result.rawSpanIds).toEqual([VALID_SPAN_ID, VALID_SPAN_ID_2]); + expect( + parsePositionalArgs([`my-org/my-project/not-a-trace/${VALID_SPAN_ID}`]), + ).toEqual({ + traceArg: "my-org/my-project/not-a-trace", + rawSpanIds: [VALID_SPAN_ID], }); - - test("still throws 'Span ID' ContextError when only a trace ID is given", () => { - expect(() => parsePositionalArgs([` ${VALID_TRACE_ID} `])).toThrow( - ContextError, - ); + expect(parsePositionalArgs([VALID_TRACE_ID, "not-a-span-id"])).toEqual({ + traceArg: VALID_TRACE_ID, + rawSpanIds: ["not-a-span-id"], }); + expect( + parsePositionalArgs([VALID_TRACE_ID, "abcd1234"]).rawSpanIds, + ).toEqual(["abcd1234"]); + }); - test("whitespace-only argument is treated as no arguments", () => { - expect(() => parsePositionalArgs([" \n "])).toThrow(ContextError); - }); + test("org/project/spanId (missing trace ID) throws a trace-focused ContextError (CLI-1GP)", () => { + try { + parsePositionalArgs([`my-org/my-project/${VALID_SPAN_ID}`]); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ContextError); + const msg = (error as ContextError).message; + // Points at the missing trace ID, not a missing span ID. + expect(msg).toContain("trace ID"); + expect(msg).toContain(VALID_SPAN_ID); + // Does not misdirect the user to `span list` with the span ID. + expect(msg).not.toContain(`span list my-org/my-project/${VALID_SPAN_ID}`); + } }); - describe("error cases", () => { - test("throws ContextError for empty args", () => { - expect(() => parsePositionalArgs([])).toThrow(ContextError); + describe("missing IDs", () => { + test.each([ + [[]], + [[" \n "]], + [[VALID_TRACE_ID]], + [[` ${VALID_TRACE_ID} `]], + [[`${VALID_TRACE_ID}/bbbb2222cccc3333dddd4444eeee5555`]], + ])("%j → ContextError", (args) => { + expect(() => parsePositionalArgs(args)).toThrow(ContextError); }); test("error message mentions trace ID and span ID", () => { - try { - parsePositionalArgs([]); - expect.unreachable("Should have thrown"); - } catch (error) { - expect(error).toBeInstanceOf(ContextError); - expect((error as ContextError).message).toContain("Trace ID"); - } - }); - - test("throws ContextError when only trace ID provided (no span IDs)", () => { - expect(() => parsePositionalArgs([VALID_TRACE_ID])).toThrow(ContextError); + expect(() => parsePositionalArgs([])).toThrow("Trace ID"); }); test("missing span IDs error suggests span list", () => { - try { - parsePositionalArgs([VALID_TRACE_ID]); - expect.unreachable("Should have thrown"); - } catch (error) { - expect(error).toBeInstanceOf(ContextError); - expect((error as ContextError).message).toContain("span list"); - } - }); - - test("accepts invalid trace ID as raw (recovery runs at command layer)", () => { - // Previously threw synchronously; now the command-level recovery - // path (parseTraceTargetWithRecovery) handles malformed trace IDs - // consistent with `trace view`. - const result = parsePositionalArgs(["not-valid", VALID_SPAN_ID]); - expect(result.kind).toBe("deferred"); - if (result.kind !== "deferred") throw new Error("unreachable"); - expect(result.rawTraceArg).toBe("not-valid"); - }); - - test("accepts invalid span ID as raw (validation deferred to command)", () => { - // parsePositionalArgs no longer validates span IDs so the command - // layer can run recovery with full trace context. - const result = parsePositionalArgs([VALID_TRACE_ID, "not-a-span-id"]); - expect(result.rawSpanIds).toEqual(["not-a-span-id"]); - }); - - test("accepts too-short span ID as raw", () => { - const result = parsePositionalArgs([VALID_TRACE_ID, "abcd1234"]); - expect(result.rawSpanIds).toEqual(["abcd1234"]); + expect(() => parsePositionalArgs([VALID_TRACE_ID])).toThrow("span list"); }); test("throws ContextError for bare span ID without trace ID (CLI-SC)", () => { @@ -377,7 +228,7 @@ describe("parsePositionalArgs", () => { test("bare span ID with dashes is still detected (CLI-SC)", () => { // Some tools format span IDs with dashes expect(() => parsePositionalArgs(["a1b2-c3d4-e5f6-7890"])).toThrow( - ContextError, + "looks like a span ID", ); }); }); diff --git a/packages/cli/test/commands/trace/view.test.ts b/packages/cli/test/commands/trace/view.test.ts index e338f260c..520b2f825 100644 --- a/packages/cli/test/commands/trace/view.test.ts +++ b/packages/cli/test/commands/trace/view.test.ts @@ -30,57 +30,30 @@ import { resolveProjectBoundSlug } from "../../../src/lib/resolve-target.js"; const VALID_TRACE_ID = "aaaa1111bbbb2222cccc3333dddd4444"; describe("preProcessArgs", () => { - test("returns args unchanged for single arg", () => { - const result = preProcessArgs([VALID_TRACE_ID]); - expect(result.correctedArgs).toEqual([VALID_TRACE_ID]); - expect(result.warning).toBeUndefined(); - expect(result.suggestion).toBeUndefined(); - }); - - test("returns args unchanged for normal two-arg order", () => { - const result = preProcessArgs(["my-org/frontend", VALID_TRACE_ID]); - expect(result.correctedArgs).toEqual(["my-org/frontend", VALID_TRACE_ID]); - expect(result.warning).toBeUndefined(); - }); - - test("detects swapped args and corrects order", () => { - // User put trace-id first, org/project second - const result = preProcessArgs([VALID_TRACE_ID, "my-org/frontend"]); - expect(result.correctedArgs).toEqual(["my-org/frontend", VALID_TRACE_ID]); - expect(result.warning).toContain("reversed"); + test("leaves trace arguments alone", () => { + expect(preProcessArgs([VALID_TRACE_ID])).toEqual({}); + expect(preProcessArgs(["my-org/frontend", VALID_TRACE_ID])).toEqual({}); + // Reversed arguments are the trace target parser's + expect(preProcessArgs([VALID_TRACE_ID, "my-org/frontend"])).toEqual({}); + expect(preProcessArgs([])).toEqual({}); }); test("detects issue short ID and suggests issue view (two args)", () => { const result = preProcessArgs(["CAM-82X", VALID_TRACE_ID]); - expect(result.correctedArgs).toEqual(["CAM-82X", VALID_TRACE_ID]); expect(result.suggestion).toContain("sentry issue view CAM-82X"); expect(result.issueShortId).toBeUndefined(); }); test("detects single-arg issue short ID for auto-recovery", () => { const result = preProcessArgs(["CLI-G5"]); - expect(result.correctedArgs).toEqual(["CLI-G5"]); expect(result.issueShortId).toBe("CLI-G5"); expect(result.suggestion).toBeUndefined(); - expect(result.warning).toBeUndefined(); }); test("detects multi-segment issue short ID for auto-recovery", () => { const result = preProcessArgs(["SPOTLIGHT-ELECTRON-4D"]); - expect(result.correctedArgs).toEqual(["SPOTLIGHT-ELECTRON-4D"]); expect(result.issueShortId).toBe("SPOTLIGHT-ELECTRON-4D"); }); - - test("does not set issueShortId for single-arg trace ID", () => { - const result = preProcessArgs([VALID_TRACE_ID]); - expect(result.issueShortId).toBeUndefined(); - }); - - test("returns empty args unchanged", () => { - const result = preProcessArgs([]); - expect(result.correctedArgs).toEqual([]); - expect(result.issueShortId).toBeUndefined(); - }); }); describe("resolveProjectBoundSlug", () => { diff --git a/packages/cli/test/lib/id-arguments.test.ts b/packages/cli/test/lib/id-arguments.test.ts index c8d3d8826..0f7cabb1f 100644 --- a/packages/cli/test/lib/id-arguments.test.ts +++ b/packages/cli/test/lib/id-arguments.test.ts @@ -1,178 +1,262 @@ /** * Tests for the ID-arguments grammar (grammar/id-arguments.pegjs), through * src/lib/id-arguments.ts. + * + * The grammar does not know any kind of ID, so the same table runs for every + * kind in `ID_KINDS`: the syntax cannot drift between commands. */ import { describe, expect, test } from "vitest"; import { ContextError, ValidationError } from "../../src/lib/errors.js"; import { + ID_KINDS, + type IdArgumentsOptions, + type IdKind, + parseIdArgument, parseIdArguments, - parseTraceOrProjectArguments, } from "../../src/lib/id-arguments.js"; -const ID = "aaaa1111bbbb2222cccc3333dddd4444"; -const HINT = "sentry trace view [//]"; -const CONTEXT = { idLabel: "Trace ID", usageHint: HINT }; +const HEX = "aaaa1111bbbb2222cccc3333dddd4444"; +const HEX2 = "bbbb2222cccc3333dddd4444eeee5555"; +const SPAN = "aaaa1111bbbb2222"; +const SPAN2 = "cccc3333dddd4444"; +const HINT = "sentry thing view [//]"; -function trace(...args: string[]) { - return parseIdArguments(args, "trace", CONTEXT); +/** An ID of each kind, and a second one */ +const EXAMPLES: Record = { + trace: [HEX, HEX2], + replay: [HEX, HEX2], + log: [HEX, HEX2], + span: [SPAN, SPAN2], +}; + +const KINDS = Object.entries(ID_KINDS).map( + ([name, kind]) => + [name, kind, ...EXAMPLES[name as keyof typeof ID_KINDS]] as const, +); + +function parse( + kind: IdKind, + args: readonly string[], + options: Partial = {}, +) { + return parseIdArguments(args, kind, { usageHint: HINT, ...options }); } -describe("trace", () => { +describe.each(KINDS)("%s IDs", (_name, kind, id, other) => { test.each([ - [[ID], { type: "none" }], - [[`/${ID}`], { type: "none" }], - [[`${ID}//`], { type: "none" }], - [[`acme/${ID}`], { type: "org", org: "acme" }], - [[`acme/web/${ID}`], { type: "org-project", org: "acme", project: "web" }], - [[`acme/web/${ID}/`], { type: "org-project", org: "acme", project: "web" }], - [[`acme/a/b/${ID}`], { type: "org-project", org: "acme", project: "a/b" }], - [["acme/web", ID], { type: "target", target: "acme/web" }], - [["My Project", ID], { type: "target", target: "My Project" }], - ])("%j → %j", (args, scope) => { - expect(trace(...args)).toEqual({ id: ID, scope, warnings: [] }); - }); - - test("trims arguments and normalizes the ID", () => { - expect(trace(` acme/${ID.toUpperCase()}\n`)).toEqual({ - id: ID, - scope: { type: "org", org: "acme" }, + [[id], undefined], + [[`/${id}`], undefined], + [[`${id}//`], undefined], + [[`acme/${id}`], "acme/"], + [[`acme/web/${id}`], "acme/web"], + [[`acme/web/${id}/`], "acme/web"], + [["acme/web", id], "acme/web"], + [["web", id], "web"], + [["My Project", id], "My Project"], + [["", id], undefined], + ])("%j → target %j", (args, target) => { + expect(parse(kind, args)).toEqual({ target, ids: [id], warnings: [] }); + }); + + test("normalizes the ID and trims arguments", () => { + expect(parse(kind, [` acme/${id.toUpperCase()}\n`])).toEqual({ + target: "acme/", + ids: [id], warnings: [], }); }); - test("a trace URL wins over the other arguments", () => { - expect( - trace(`https://acme.sentry.io/explore/traces/trace/${ID}/`, "extra"), - ).toEqual({ - id: ID, - scope: { type: "org", org: "acme" }, - baseUrl: "https://acme.sentry.io", + test("reads reversed arguments, with a warning", () => { + expect(parse(kind, [id, "acme/web"])).toEqual({ + target: "acme/web", + ids: [id], + warnings: [`Arguments appear reversed. Interpreting as: acme/web ${id}`], + }); + }); + + test("splits IDs off a target at spaces, and at line breaks", () => { + const expected = { target: "acme/web", ids: [id, other], warnings: [] }; + const multiple = { multiple: true }; + expect(parse(kind, [`acme/web ${id} ${other}`], multiple)).toEqual( + expected, + ); + expect(parse(kind, ["acme/web", `${id}\n${other}\n`], multiple)).toEqual( + expected, + ); + // A display name keeps its spaces + expect(parse(kind, [`My Project ${id}`])).toEqual({ + target: "My Project", + ids: [id], warnings: [], }); }); - test("a trace ID path is not a target for a second trace ID", () => { - const other = "bbbb2222cccc3333dddd4444eeee5555"; - expect(trace(`acme/web/${ID}`, other)).toEqual({ - id: ID, - scope: { type: "org-project", org: "acme", project: "web" }, + test("more IDs may repeat the target", () => { + expect( + parse(kind, [`acme/web/${id}`, `acme/web/${other}`], { multiple: true }), + ).toEqual({ target: "acme/web", ids: [id, other], warnings: [] }); + expect( + parse(kind, ["acme/web", `acme/web/${other}`], { multiple: true }), + ).toEqual({ target: "acme/web", ids: [other], warnings: [] }); + }); + + test("IDs from different targets are an error", () => { + expect(() => + parse(kind, [`acme/web/${id}`, `acme/api/${other}`], { multiple: true }), + ).toThrow(ValidationError); + }); + + test("keeps one ID, ignoring the rest with a warning", () => { + expect(parse(kind, ["web", "extra", id])).toEqual({ + target: "web", + ids: [id], + warnings: [`Extra arguments ignored: extra. Expected: ${HINT}`], + }); + expect(parse(kind, [`acme/web/${id}`, other])).toEqual({ + target: "acme/web", + ids: [id], warnings: [`Extra arguments ignored: ${other}. Expected: ${HINT}`], }); }); - test("warns about arguments after the trace ID", () => { - expect(trace("acme/web", ID, "a", "b").warnings).toEqual([ - `Extra arguments ignored: a b. Expected: ${HINT}`, - ]); - }); + test.each([[[]], [[""]], [["acme/web"]], [["acme/web/"]], [["/"]]])( + "%j → missing ID", + (args) => { + expect(() => parse(kind, args)).toThrow(ContextError); + }, + ); - test.each([[[]], [["acme/web/"]], [["acme/"]], [["/"]]])( - "%j → missing trace ID", + test.each([[[`acme//${id}`]], [[`/web/${id}`]]])( + "%j → empty org or project", (args) => { - expect(() => trace(...args)).toThrow(ContextError); + expect(() => parse(kind, args)).toThrow(ValidationError); }, ); test.each([ - [[""]], - [["not-hex"]], - [["acme/not-hex"]], - [["acme/web", "not-hex"]], - [[`acme//${ID}`]], - [[`/web/${ID}`]], - ])("%j → invalid", (args) => { - expect(() => trace(...args)).toThrow(ValidationError); + [["not-hex"], undefined], + [["acme/web/not-hex"], "acme/web"], + [["acme/web", "not-hex"], "acme/web"], + [["not-hex", "acme/web"], "acme/web"], + ])("%j → invalid ID, or raw ID and target %j", (args, target) => { + expect(() => parse(kind, args)).toThrow(ValidationError); + expect(parse(kind, args, { validate: false })).toMatchObject({ + target, + ids: ["not-hex"], + }); + }); + + test("a command that needs a project reads `/` as the project", () => { + expect(parse(kind, [`web/${id}`], { needsProject: true })).toEqual({ + target: "web", + ids: [id], + warnings: [], + }); }); - test("rejects NUL in an argument", () => { - expect(() => trace(`acme\0web`, ID)).toThrow(ValidationError); + describe("optional IDs", () => { + const optional = { optional: true }; + + test.each([ + [[], undefined], + [["web"], "web"], + [["acme/"], "acme/"], + [["acme/web"], "acme/web"], + [["acme/web/"], "acme/web/"], + ])("%j → target %j, no ID", (args, target) => { + expect(parse(kind, args, optional)).toEqual({ + target, + ids: [], + warnings: [], + }); + }); + + test.each([ + [[id], undefined], + [[`acme/${id}`], "acme/"], + [["acme/web", id], "acme/web"], + ])("%j → target %j and the ID", (args, target) => { + expect(parse(kind, args, optional)).toEqual({ + target, + ids: [id], + warnings: [], + }); + }); + + test("an argument after the target is an ID", () => { + expect(() => parse(kind, ["web", "not-hex"], optional)).toThrow( + ValidationError, + ); + }); }); }); -describe("tracePath", () => { - test("does not read URLs", () => { - expect(() => - parseIdArguments( - [`https://acme.sentry.io/explore/traces/trace/${ID}`], - "tracePath", - CONTEXT, - ), - ).not.toThrow(); +describe("parseIdArgument", () => { + test("returns the one ID", () => { expect( - parseIdArguments([`acme/web/${ID}`], "tracePath", CONTEXT).scope, - ).toEqual({ type: "org-project", org: "acme", project: "web" }); + parseIdArgument(["acme/web", HEX], ID_KINDS.trace, { usageHint: HINT }), + ).toEqual({ target: "acme/web", id: HEX, warnings: [] }); }); }); -describe("traceOrProject", () => { - const parse = (...args: string[]) => parseTraceOrProjectArguments(args, HINT); +describe("URLs", () => { + const traceUrl = `https://acme.sentry.io/explore/traces/trace/${HEX}/`; + const replayUrl = `https://sentry.io/organizations/acme/explore/replays/${HEX}/`; - test.each([ - [[], undefined], - [["acme/web"], "acme/web"], - [["acme/"], "acme/"], - [["web", "not-a-trace"], "web"], - ])("%j → project mode", (args, target) => { - expect(parse(...args)).toEqual({ mode: "project", target }); - }); - - test("a trailing trace ID selects trace mode", () => { - expect(parse("acme/web", ID)).toEqual({ - mode: "trace", - trace: { - id: ID, - scope: { type: "target", target: "acme/web" }, - warnings: [], - }, + test("a URL with the command's kind of ID names the ID and its org", () => { + expect(parse(ID_KINDS.trace, [traceUrl])).toEqual({ + target: "acme/", + ids: [HEX], + baseUrl: "https://acme.sentry.io", + warnings: [], + }); + expect(parse(ID_KINDS.replay, [replayUrl])).toEqual({ + target: "acme/", + ids: [HEX], + baseUrl: "https://sentry.io", + warnings: [], }); }); - test("the trace ID is the last argument, the target the first", () => { - // The mode and the trace ID come from the same argument; a subcommand - // name or stray word in between is ignored, not read as the trace ID - expect(parse("acme/web", "view", ID)).toEqual({ - mode: "trace", - trace: { - id: ID, - scope: { type: "target", target: "acme/web" }, - warnings: [`Extra arguments ignored: view. Expected: ${HINT}`], - }, - }); + test("a URL without one is missing the ID, with an example URL", () => { + try { + parse(ID_KINDS.replay, [traceUrl]); + expect.unreachable(); + } catch (error) { + expect(error).toBeInstanceOf(ContextError); + expect((error as ContextError).message).toContain( + "Pass a replay URL: https://sentry.io/organizations/{org}/explore/replays/{replayId}/", + ); + } }); - test("a trace ID path selects trace mode", () => { - expect(parse(`acme/${ID}/`)).toMatchObject({ - mode: "trace", - trace: { id: ID, scope: { type: "org", org: "acme" } }, - }); - expect(parse(`acme/web/${ID}`, "extra")).toMatchObject({ - mode: "trace", - trace: { - scope: { type: "org-project", org: "acme", project: "web" }, - warnings: [`Extra arguments ignored: extra. Expected: ${HINT}`], - }, + test("a URL with no ID of any kind is a target when IDs are optional", () => { + const projectUrl = "https://sentry.io/organizations/acme/projects/web/"; + expect(parse(ID_KINDS.trace, [projectUrl], { optional: true })).toEqual({ + target: projectUrl, + ids: [], + warnings: [], }); }); +}); - test("a trace ID path wins over a trailing trace ID", () => { - const other = "bbbb2222cccc3333dddd4444eeee5555"; - expect(parse(`acme/${ID}`, other)).toEqual({ - mode: "trace", - trace: { - id: ID, - scope: { type: "org", org: "acme" }, - warnings: [`Extra arguments ignored: ${other}. Expected: ${HINT}`], - }, - }); +describe("tokens", () => { + test("rejects NUL", () => { + expect(() => parse(ID_KINDS.trace, [`a\0b`])).toThrow(ValidationError); }); - test("a trace URL selects trace mode", () => { + test("a span ID's trace is its target", () => { expect( - parse(`https://sentry.io/organizations/acme/traces/${ID}/`), - ).toMatchObject({ - mode: "trace", - trace: { id: ID, scope: { type: "org", org: "acme" } }, - }); + parse(ID_KINDS.span, [`acme/web/${HEX}/${SPAN}`, SPAN2], { + multiple: true, + }), + ).toEqual({ target: `acme/web/${HEX}`, ids: [SPAN, SPAN2], warnings: [] }); + expect( + parse(ID_KINDS.span, [`${HEX} ${SPAN}`], { + multiple: true, + optional: true, + }), + ).toEqual({ target: HEX, ids: [SPAN], warnings: [] }); }); }); diff --git a/packages/cli/test/lib/trace-target.test.ts b/packages/cli/test/lib/trace-target.test.ts index aa81a8246..49422e251 100644 --- a/packages/cli/test/lib/trace-target.test.ts +++ b/packages/cli/test/lib/trace-target.test.ts @@ -1,8 +1,9 @@ /** * Tests for shared trace-target parsing and resolution. * - * Tests parseTraceTarget, parseSlashSeparatedTraceTarget, - * and targetArgToTraceTarget from src/lib/trace-target.ts. + * Tests parseTraceTarget, the dual-mode parsers, and the resolvers from + * src/lib/trace-target.ts. The syntax itself is tested in + * id-arguments.test.ts. */ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; @@ -26,51 +27,41 @@ import { ValidationError, } from "../../src/lib/errors.js"; import { - extractRawTraceId, parseDualModeArgs, parseDualModeInput, - parseSlashSeparatedTraceTarget, parseTraceTarget, parseTraceTargetInput, resolveTraceOrg, resolveTraceOrgOptionalProject, resolveTraceOrgProject, - stripTrailingSlashesAfterTraceId, - targetArgToTraceTarget, } from "../../src/lib/trace-target.js"; const VALID_TRACE_ID = "aaaa1111bbbb2222cccc3333dddd4444"; -describe("parseSlashSeparatedTraceTarget", () => { +describe("parseTraceTarget with one argument", () => { const HINT = "sentry span list [//]"; test("bare trace ID → auto-detect", () => { - const result = parseSlashSeparatedTraceTarget(VALID_TRACE_ID, HINT); + const result = parseTraceTarget([VALID_TRACE_ID], HINT); expect(result.type).toBe("auto-detect"); expect(result.traceId).toBe(VALID_TRACE_ID); }); test("normalizes uppercase trace ID", () => { - const result = parseSlashSeparatedTraceTarget( - "AAAA1111BBBB2222CCCC3333DDDD4444", - HINT, - ); + const result = parseTraceTarget(["AAAA1111BBBB2222CCCC3333DDDD4444"], HINT); expect(result.traceId).toBe(VALID_TRACE_ID); }); test("strips UUID dashes from trace ID", () => { - const result = parseSlashSeparatedTraceTarget( - "aaaa1111-bbbb-2222-cccc-3333dddd4444", + const result = parseTraceTarget( + ["aaaa1111-bbbb-2222-cccc-3333dddd4444"], HINT, ); expect(result.traceId).toBe(VALID_TRACE_ID); }); test("org/trace-id → org-scoped", () => { - const result = parseSlashSeparatedTraceTarget( - `my-org/${VALID_TRACE_ID}`, - HINT, - ); + const result = parseTraceTarget([`my-org/${VALID_TRACE_ID}`], HINT); expect(result.type).toBe("org-scoped"); expect(result.traceId).toBe(VALID_TRACE_ID); if (result.type === "org-scoped") { @@ -79,8 +70,8 @@ describe("parseSlashSeparatedTraceTarget", () => { }); test("org/project/trace-id → explicit", () => { - const result = parseSlashSeparatedTraceTarget( - `my-org/my-project/${VALID_TRACE_ID}`, + const result = parseTraceTarget( + [`my-org/my-project/${VALID_TRACE_ID}`], HINT, ); expect(result.type).toBe("explicit"); @@ -94,8 +85,8 @@ describe("parseSlashSeparatedTraceTarget", () => { test("preserves underscores in slugs (see #770)", () => { // Sentry accepts underscores in project slugs, so they must flow // through the trace-target parser unchanged. - const result = parseSlashSeparatedTraceTarget( - `my_org/my_project/${VALID_TRACE_ID}`, + const result = parseTraceTarget( + [`my_org/my_project/${VALID_TRACE_ID}`], HINT, ); expect(result.type).toBe("explicit"); @@ -107,27 +98,31 @@ describe("parseSlashSeparatedTraceTarget", () => { }); test("trailing slash without trace ID throws", () => { - expect(() => parseSlashSeparatedTraceTarget("my-org/", HINT)).toThrow( - ContextError, - ); + expect(() => parseTraceTarget(["my-org/"], HINT)).toThrow(ContextError); }); test("invalid trace ID throws ValidationError", () => { - expect(() => - parseSlashSeparatedTraceTarget("not-a-trace-id", HINT), - ).toThrow(ValidationError); + expect(() => parseTraceTarget(["not-a-trace-id"], HINT)).toThrow( + ValidationError, + ); }); }); -describe("targetArgToTraceTarget", () => { +describe("parseTraceTarget with a target argument", () => { test("explicit target (org/project)", () => { - const result = targetArgToTraceTarget("my-org/my-project", VALID_TRACE_ID); + const result = parseTraceTarget( + ["my-org/my-project", VALID_TRACE_ID], + "sentry trace view ", + ); expect(result.type).toBe("explicit"); expect(result.traceId).toBe(VALID_TRACE_ID); }); test("org-all target (org/) → org-scoped", () => { - const result = targetArgToTraceTarget("my-org/", VALID_TRACE_ID); + const result = parseTraceTarget( + ["my-org/", VALID_TRACE_ID], + "sentry trace view ", + ); expect(result.type).toBe("org-scoped"); if (result.type === "org-scoped") { expect(result.org).toBe("my-org"); @@ -135,7 +130,10 @@ describe("targetArgToTraceTarget", () => { }); test("bare slug → project-search", () => { - const result = targetArgToTraceTarget("frontend", VALID_TRACE_ID); + const result = parseTraceTarget( + ["frontend", VALID_TRACE_ID], + "sentry trace view ", + ); expect(result.type).toBe("project-search"); if (result.type === "project-search") { expect(result.projectSlug).toBe("frontend"); @@ -143,7 +141,10 @@ describe("targetArgToTraceTarget", () => { }); test("preserves scoped display-name metadata", () => { - const result = targetArgToTraceTarget("my-org/My Project", VALID_TRACE_ID); + const result = parseTraceTarget( + ["my-org/My Project", VALID_TRACE_ID], + "sentry trace view ", + ); expect(result).toMatchObject({ type: "project-search", org: "my-org", @@ -153,7 +154,10 @@ describe("targetArgToTraceTarget", () => { }); test("empty string → auto-detect", () => { - const result = targetArgToTraceTarget("", VALID_TRACE_ID); + const result = parseTraceTarget( + ["", VALID_TRACE_ID], + "sentry trace view ", + ); expect(result.type).toBe("auto-detect"); }); }); @@ -181,7 +185,10 @@ describe("trace target resolution", () => { ], orgs: [{ slug: "project-owner", name: "Project Owner" }], }); - const parsed = targetArgToTraceTarget("frontend", VALID_TRACE_ID); + const parsed = parseTraceTarget( + ["frontend", VALID_TRACE_ID], + "sentry trace view ", + ); const resolved = await resolveTraceOrg( parsed, @@ -194,7 +201,10 @@ describe("trace target resolution", () => { test("project-bound errors preserve the full trace usage hint", async () => { findProjectsBySlugSpy.mockResolvedValue({ projects: [], orgs: [] }); - const parsed = targetArgToTraceTarget("missing", VALID_TRACE_ID); + const parsed = parseTraceTarget( + ["missing", VALID_TRACE_ID], + "sentry trace view ", + ); const usageHint = "sentry span view [//]"; try { @@ -208,7 +218,10 @@ describe("trace target resolution", () => { test("org-capable errors preserve the full trace usage hint", async () => { findProjectsBySlugSpy.mockResolvedValue({ projects: [], orgs: [] }); - const parsed = targetArgToTraceTarget("missing", VALID_TRACE_ID); + const parsed = parseTraceTarget( + ["missing", VALID_TRACE_ID], + "sentry trace view ", + ); const usageHint = "sentry trace view [//]"; try { @@ -335,8 +348,8 @@ describe("trailing slashes and Sentry trace URLs (CLI-13F)", () => { }); test("org/project// → explicit", () => { - const result = parseSlashSeparatedTraceTarget( - `my-org/my-project/${VALID_TRACE_ID}/`, + const result = parseTraceTarget( + [`my-org/my-project/${VALID_TRACE_ID}/`], HINT, ); expect(result).toEqual({ @@ -348,28 +361,13 @@ describe("trailing slashes and Sentry trace URLs (CLI-13F)", () => { }); test("/ → auto-detect", () => { - const result = parseSlashSeparatedTraceTarget(`${VALID_TRACE_ID}/`, HINT); + const result = parseTraceTarget([`${VALID_TRACE_ID}/`], HINT); expect(result).toEqual({ type: "auto-detect", traceId: VALID_TRACE_ID }); }); test("org/project/ still throws ContextError (trace ID omitted)", () => { - expect(() => - parseSlashSeparatedTraceTarget("my-org/my-project/", HINT), - ).toThrow(ContextError); - }); - - test("stripTrailingSlashesAfterTraceId leaves non-trace tails alone", () => { - expect(stripTrailingSlashesAfterTraceId("my-org/my-project/")).toBe( - "my-org/my-project/", - ); - expect(stripTrailingSlashesAfterTraceId(`org/${VALID_TRACE_ID}//`)).toBe( - `org/${VALID_TRACE_ID}`, - ); - }); - - test("extractRawTraceId ignores a trailing slash after the trace ID", () => { - expect(extractRawTraceId([`my-org/my-project/${VALID_TRACE_ID}/`])).toEqual( - { rawTraceId: VALID_TRACE_ID, targetArg: "my-org/my-project" }, + expect(() => parseTraceTarget(["my-org/my-project/"], HINT)).toThrow( + ContextError, ); }); diff --git a/scripts/cli-lint-rules.test.mjs b/scripts/cli-lint-rules.test.mjs index 46bc0c351..a0e397338 100644 --- a/scripts/cli-lint-rules.test.mjs +++ b/scripts/cli-lint-rules.test.mjs @@ -124,6 +124,42 @@ tester.run("no-silent-catch", plugin.rules["no-silent-catch"], { ], }); +tester.run("no-ad-hoc-id-arguments", plugin.rules["no-ad-hoc-id-arguments"], { + valid: [ + { + filename: commands, + code: "import { HEX_ID_RE } from './hex-id.js'; parseIdArguments(args, ID_KINDS.trace, options);", + }, + { + // Issue and event IDs keep their own syntax + filename: "/repo/packages/cli/src/commands/event/view.ts", + code: "parseSlashSeparatedArg(arg, label, hint);", + }, + { + filename: "/repo/packages/cli/src/lib/id-arguments.ts", + code: "parseIdArguments(args, kind, options);", + }, + ], + invalid: [ + { + filename: commands, + code: "parseSlashSeparatedArg(arg, label, hint);", + errors: 1, + }, + { + filename: commands, + code: "const swapped = detectSwappedViewArgs(first, second);", + errors: 1, + }, + { filename: commands, code: "HEX_ID_RE.test(value);", errors: 1 }, + { + filename: source, + code: "parseIdArguments(args, { label: 'Thing ID', isId }, options);", + errors: 1, + }, + ], +}); + tester.run("no-raw-metadata-queries", plugin.rules["no-raw-metadata-queries"], { valid: [ { diff --git a/scripts/generate-parsers.mjs b/scripts/generate-parsers.mjs index 6508aa54c..fb6248543 100644 --- a/scripts/generate-parsers.mjs +++ b/scripts/generate-parsers.mjs @@ -8,9 +8,7 @@ * editors and linters check in place. The generator copies it to * `src/generated/-grammar.d.ts`. Relative module specifiers, found * with the TypeScript parser, are rebased so they resolve to the same - * modules from there; nothing else in the file changes. The string literal - * types of its `startRule` properties are the grammar's allowed start rules - * (by default only the first rule). + * modules from there; nothing else in the file changes. * * The CLI generates its parsers at build time (`pnpm run generate:grammar` * in packages/cli). @@ -83,32 +81,6 @@ function rebaseDeclarations(source, fileName) { return result; } -/** The string literals a `startRule` property's type allows. */ -function startRules(source, fileName) { - const file = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest); - const rules = new Set(); - const addLiterals = (type) => { - if (ts.isUnionTypeNode(type)) { - type.types.forEach(addLiterals); - } else if (ts.isLiteralTypeNode(type) && ts.isStringLiteral(type.literal)) { - rules.add(type.literal.text); - } - }; - const visit = (node) => { - if ( - ts.isPropertySignature(node) && - node.type && - ts.isIdentifier(node.name) && - node.name.text === "startRule" - ) { - addLiterals(node.type); - } - ts.forEachChild(node, visit); - }; - visit(file); - return [...rules]; -} - const grammars = (await readdir(grammarDir)).filter((file) => file.endsWith(".pegjs"), ); @@ -122,14 +94,15 @@ for (const file of grammars) { throw new Error(`${declarationsPath} is missing`); } - const declarationsSource = await readFile(declarationsPath, "utf-8"); const parserSource = peggy.generate(await readFile(grammarPath, "utf-8"), { output: "source", format: "es", grammarSource: relative(packageDir, grammarPath).split(sep).join("/"), - allowedStartRules: startRules(declarationsSource, declarationsPath), }); - const declarations = rebaseDeclarations(declarationsSource, declarationsPath); + const declarations = rebaseDeclarations( + await readFile(declarationsPath, "utf-8"), + declarationsPath, + ); const outputBase = join(outputDir, `${name}-grammar`); await writeFile(`${outputBase}.js`, `${HEADER}\n${parserSource}\n`); From 076b3a1434dff8acdbeca19f364ed826ec16ab92 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:31:36 +0000 Subject: [PATCH 09/13] fix(cli): Recover a malformed ID after the org `/` with a truncated, ellipsized, or junk-suffixed ID parsed as an org/project target with no ID, so trace commands said the trace ID was required instead of recovering it from the prefix. Read the last segment as an ID when it has enough hex for recovery to look up (the same check recovery uses) and is not a slug or a shell sentinel. `/` is still a target without an ID, and so is any token when IDs are optional. --- packages/cli/grammar/id-arguments.d.ts | 2 + packages/cli/grammar/id-arguments.pegjs | 7 ++- packages/cli/src/lib/hex-id-recovery.ts | 35 ++++++++++++-- packages/cli/src/lib/id-arguments.ts | 9 ++++ packages/cli/test/lib/id-arguments.test.ts | 24 ++++++++++ packages/cli/test/lib/trace-target.test.ts | 55 ++++++++++++++++++++++ 6 files changed, 125 insertions(+), 7 deletions(-) diff --git a/packages/cli/grammar/id-arguments.d.ts b/packages/cli/grammar/id-arguments.d.ts index 6e291e1c6..e891f0631 100644 --- a/packages/cli/grammar/id-arguments.d.ts +++ b/packages/cli/grammar/id-arguments.d.ts @@ -18,6 +18,8 @@ export type IdArgumentsGrammarOptions = { optional: boolean; /** Whether `value` is an ID of the kind the command takes. */ isId: (value: string) => boolean; + /** Whether `value`, not an ID, still looks like a malformed one. */ + mayBeId: (value: string) => boolean; /** The ID in a Sentry URL, or `null` for anything else. */ idUrl: (value: string) => IdUrl | null; }; diff --git a/packages/cli/grammar/id-arguments.pegjs b/packages/cli/grammar/id-arguments.pegjs index 3d1606e1e..8866e6c86 100644 --- a/packages/cli/grammar/id-arguments.pegjs +++ b/packages/cli/grammar/id-arguments.pegjs @@ -26,7 +26,7 @@ // the IDs, validates them, and parses the target like any org/project target. { - const { optional, isId, idUrl } = options; + const { optional, isId, mayBeId, idUrl } = options; // `[/[/]]` before an ID; the caller makes it a target function pathTarget(prefix) { @@ -67,8 +67,11 @@ arguments } // One token that does not end in an ID. Unless IDs are optional, it still // names one, so it can be reported or recovered: the last segment after - // two slashes (`//`), or the whole token. With one slash, + // two slashes (`//`), after one when it looks like a + // malformed ID (`/`), or the whole token. Otherwise, // `/` is a target without an ID. + / sep t:segment "/" i:segment end &{ return !optional && mayBeId(i); } + { return { ...pathTarget(t), ids: [{ text: text().slice(1), id: i }] }; } / sep !"/" t:$(segment "/" segment "/" [^\0]*) end &{ return !optional; } { const slash = t.lastIndexOf("/"); return { diff --git a/packages/cli/src/lib/hex-id-recovery.ts b/packages/cli/src/lib/hex-id-recovery.ts index e6d7dadfe..e301c3827 100644 --- a/packages/cli/src/lib/hex-id-recovery.ts +++ b/packages/cli/src/lib/hex-id-recovery.ts @@ -343,6 +343,35 @@ export function looksLikeSlug(input: string): boolean { return !(PURE_HEX_RE.test(dashless) && dashless.length >= MIN_FUZZY_PREFIX); } +/** + * Whether `input`, not a valid ID, still has enough of one for + * {@link recoverHexId} to look it up: a hex prefix or ellipsized ID of at + * least {@link MIN_FUZZY_PREFIX} chars that is not a slug. The ID argument + * grammar uses it to read `/` as an ID to recover, not as a + * project. + */ +export function looksLikeHexIdFragment(input: string): boolean { + if (looksLikeSlug(input)) { + return false; + } + const { cleaned, sentinel } = preNormalize(input); + if (sentinel) { + return false; + } + return isLongEnough(extractHexCandidate(cleaned)); +} + +/** Whether `candidate` has enough hex chars for a fuzzy lookup */ +function isLongEnough( + candidate: HexCandidate | null, +): candidate is HexCandidate { + return ( + candidate !== null && + (candidate.prefix.length >= MIN_FUZZY_PREFIX || + (candidate.suffix?.length ?? 0) >= MIN_FUZZY_PREFIX) + ); +} + /** Returns true for paths with 4+ slash-separated non-empty segments. */ export function isOverNestedPath(input: string): boolean { return input.split("/").filter(Boolean).length >= OVER_NESTED_MIN_SEGMENTS; @@ -795,11 +824,7 @@ async function recoverHexIdInternal( } const candidate = extractHexCandidate(cleaned); - const longEnough = - candidate !== null && - (candidate.prefix.length >= MIN_FUZZY_PREFIX || - (candidate.suffix?.length ?? 0) >= MIN_FUZZY_PREFIX); - if (!(longEnough && candidate)) { + if (!isLongEnough(candidate)) { // Raw-input slug check already ran above; here it's either a short // pure-hex prefix or a short non-hex blob — neither is a slug. return { diff --git a/packages/cli/src/lib/id-arguments.ts b/packages/cli/src/lib/id-arguments.ts index 6b3a9def4..d9072972e 100644 --- a/packages/cli/src/lib/id-arguments.ts +++ b/packages/cli/src/lib/id-arguments.ts @@ -34,6 +34,7 @@ import { validateHexId, validateSpanId, } from "./hex-id.js"; +import { looksLikeHexIdFragment } from "./hex-id-recovery.js"; import { type ParsedSentryUrl, parseSentryUrl } from "./sentry-url-parser.js"; /** A kind of ID a command takes. */ @@ -42,6 +43,11 @@ export type IdKind = { readonly label: string; /** Whether `value` is an ID of this kind (after normalizing) */ readonly isId: (value: string) => boolean; + /** + * Whether `value`, not an ID, still looks like a malformed one, so + * `/` names an ID to report or recover, not a project + */ + readonly mayBeId: (value: string) => boolean; /** * Normalize an ID. * @@ -63,6 +69,7 @@ function hexKind( return { label, isId: (value) => tryNormalizeHexId(value) !== undefined, + mayBeId: looksLikeHexIdFragment, validate: (value) => validateHexId(value, lower), fromUrl, urlExample, @@ -82,6 +89,7 @@ export const ID_KINDS = { span: { label: "Span ID", isId: isSpanId, + mayBeId: looksLikeHexIdFragment, validate: validateSpanId, fromUrl: () => undefined, }, @@ -148,6 +156,7 @@ export function parseIdArguments( const syntax = parse(tokens.map((token) => `\0${token}`).join(""), { optional, isId: kind.isId, + mayBeId: kind.mayBeId, idUrl: (value) => idUrl(value, kind), }); const warnings: string[] = []; diff --git a/packages/cli/test/lib/id-arguments.test.ts b/packages/cli/test/lib/id-arguments.test.ts index 0f7cabb1f..bb70921f5 100644 --- a/packages/cli/test/lib/id-arguments.test.ts +++ b/packages/cli/test/lib/id-arguments.test.ts @@ -147,6 +147,29 @@ describe.each(KINDS)("%s IDs", (_name, kind, id, other) => { }); }); + test.each([ + [`acme/${id.slice(0, 12)}`, id.slice(0, 12)], + [ + `acme/${id.slice(0, 8)}...${id.slice(-8)}`, + `${id.slice(0, 8)}...${id.slice(-8)}`, + ], + [`acme/${id}xyz`, `${id}xyz`], + ])("%j → invalid ID, or raw ID %j after the org", (arg, raw) => { + expect(() => parse(kind, [arg])).toThrow(ValidationError); + expect(parse(kind, [arg], { validate: false })).toEqual({ + target: "acme/", + ids: [raw], + warnings: [], + }); + }); + + test.each([["acme/abc123"], ["acme/deadbeef-app"], ["acme/null"]])( + "%j does not look like an ID → missing ID", + (arg) => { + expect(() => parse(kind, [arg])).toThrow(ContextError); + }, + ); + test("a command that needs a project reads `/` as the project", () => { expect(parse(kind, [`web/${id}`], { needsProject: true })).toEqual({ target: "web", @@ -164,6 +187,7 @@ describe.each(KINDS)("%s IDs", (_name, kind, id, other) => { [["acme/"], "acme/"], [["acme/web"], "acme/web"], [["acme/web/"], "acme/web/"], + [[`acme/${id.slice(0, 12)}`], `acme/${id.slice(0, 12)}`], ])("%j → target %j, no ID", (args, target) => { expect(parse(kind, args, optional)).toEqual({ target, diff --git a/packages/cli/test/lib/trace-target.test.ts b/packages/cli/test/lib/trace-target.test.ts index 49422e251..3d0fb3562 100644 --- a/packages/cli/test/lib/trace-target.test.ts +++ b/packages/cli/test/lib/trace-target.test.ts @@ -19,6 +19,15 @@ vi.mock("../../src/lib/api-client.js", async (importOriginal) => { ); }); +vi.mock("../../src/lib/region.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + resolveEffectiveOrg: vi.fn(actual.resolveEffectiveOrg), + }; +}); + // oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for spyOn mocking import * as apiClient from "../../src/lib/api-client.js"; import { @@ -26,11 +35,14 @@ import { ResolutionError, ValidationError, } from "../../src/lib/errors.js"; +// oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for spyOn mocking +import * as region from "../../src/lib/region.js"; import { parseDualModeArgs, parseDualModeInput, parseTraceTarget, parseTraceTargetInput, + parseTraceTargetWithRecovery, resolveTraceOrg, resolveTraceOrgOptionalProject, resolveTraceOrgProject, @@ -433,3 +445,46 @@ describe("parsing without side effects", () => { }); }); }); + +describe("parseTraceTargetWithRecovery", () => { + const HINT = "sentry trace view [/[/]]"; + + beforeEach(() => { + vi.spyOn(region, "resolveEffectiveOrg").mockImplementation( + async (org) => org, + ); + }); + + afterEach(() => { + vi.mocked(region.resolveEffectiveOrg).mockRestore(); + }); + + test("recovers a malformed trace ID after the org", async () => { + await expect( + parseTraceTargetWithRecovery([`acme/${VALID_TRACE_ID}xyz`], HINT), + ).resolves.toEqual({ + type: "org-scoped", + traceId: VALID_TRACE_ID, + org: "acme", + }); + }); + + test("a partial trace ID after the org reaches recovery", async () => { + // Without a project there is nothing to look the prefix up in, so + // recovery finds no match, rather than the trace ID being missing. + const prefix = VALID_TRACE_ID.slice(0, 12); + await expect( + parseTraceTargetWithRecovery([`acme/${prefix}`], HINT), + ).rejects.toMatchObject({ + constructor: ResolutionError, + resource: `Trace '${prefix}'`, + headline: "not found", + }); + }); + + test("/ is still missing the trace ID", async () => { + await expect( + parseTraceTargetWithRecovery(["acme/web"], HINT), + ).rejects.toThrow(ContextError); + }); +}); From 9a899d4530b392b632c53feba1c976f59887181c Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:50:05 +0000 Subject: [PATCH 10/13] ref(cli): Parse trace, span and log view arguments in the scanner `trace view`, `span view` and `log view` were the last ID-taking commands parsing their positionals in `func`. They now use `boundArguments`, like `replay view` and `log list`: the parse runs in the argument scanner with no side effects, so a missing ID or a bad target fails like any other argument (exit 252) before auth or any lookup, and `func` awaits `target.resolved`. These commands recover malformed IDs, which needs the API, so the parse keeps IDs as typed and `resolve` validates or recovers them. `parseTraceTargetWithRecovery` splits into the two halves: `parseTraceArgumentsInput` (pure) and `resolveTraceArguments` (applies a pasted URL's server and warnings, then validates or recovers the trace ID). `log view` parses its target with `parseOrgProjectInput` and applies the server when it resolves. Co-Authored-By: BYK --- packages/cli/AGENTS.md | 10 +- packages/cli/lint-rules/cli-oxlint-plugin.js | 3 + packages/cli/src/commands/log/view.ts | 114 ++++++++------ packages/cli/src/commands/span/view.ts | 131 +++++++++-------- packages/cli/src/commands/trace/view.ts | 139 ++++++++++-------- packages/cli/src/lib/trace-target.ts | 82 ++++++----- packages/cli/test/commands/span/view.test.ts | 73 +++++---- .../cli/test/commands/trace/view.func.test.ts | 34 +++++ packages/cli/test/commands/trace/view.test.ts | 70 ++++++--- packages/cli/test/lib/trace-target.test.ts | 43 ++++-- 10 files changed, 434 insertions(+), 265 deletions(-) diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index ccd9d416e..64b4d505c 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -296,8 +296,8 @@ call `command.loader()` keep passing raw strings. **Arity-dependent positionals:** when what an argument means depends on how many were given (`[/] `, `log list`'s optional trailing trace ID), declare the positional with `boundArguments` from -`src/lib/positional-parse.ts` (see `replayArguments` in `replay/view.ts` and -`log list`). It is an array positional whose `parse` sees every argument at +`src/lib/positional-parse.ts` (see `replayArguments` in `replay/view.ts`, +`log list`, and `trace view`). It is an array positional whose `parse` sees every argument at once and whose `resolve` binds like a target positional's: `func` takes one `BoundArguments` (alias it per command and add the alias to the lint rule's type list), reads `target.parsed`, and awaits @@ -308,7 +308,11 @@ parse error becomes Stricli's `ArgumentParseError`, which prints and exits command's help instead. The same purity rule applies: return a pasted URL's server (`baseUrl`) and any warnings from the parse, and apply them in `resolve` (`parseTraceTargetInput` and `parseDualModeInput` in `trace-target.ts` do -this for trace targets). +this for trace targets). Commands that recover a malformed ID (`trace view`, +`span view`, `log view`) parse it unvalidated and validate or recover it in +`resolve`, which can look the prefix up: `parseTraceArgumentsInput` and +`resolveTraceArguments` do this for trace IDs. A missing ID or a bad target +still fails the parse. Which argument is the ID and which is its target is decided by `grammar/id-arguments.pegjs`, not by counting arguments in TypeScript. The diff --git a/packages/cli/lint-rules/cli-oxlint-plugin.js b/packages/cli/lint-rules/cli-oxlint-plugin.js index 637a0071a..d3cfcfecd 100644 --- a/packages/cli/lint-rules/cli-oxlint-plugin.js +++ b/packages/cli/lint-rules/cli-oxlint-plugin.js @@ -66,7 +66,10 @@ const BOUND_TARGET_TYPES = new Set([ "ProjectBoundTarget", "BoundArguments", "LogListArguments", + "LogViewArguments", "ReplayArguments", + "SpanViewArguments", + "TraceViewArguments", ]); function boundTargetParams(fn) { diff --git a/packages/cli/src/commands/log/view.ts b/packages/cli/src/commands/log/view.ts index e04f975e8..7115d767f 100644 --- a/packages/cli/src/commands/log/view.ts +++ b/packages/cli/src/commands/log/view.ts @@ -12,11 +12,13 @@ import type { SentryContext } from "../../context.js"; import { getLogItemDetail, getLogs } from "../../lib/api-client.js"; import { looksLikeIssueShortId, - parseOrgProjectArg, + type OrgProjectInput, + type ParsedOrgProject, + parseOrgProjectInput, } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; -import { parseOrgProjectTarget } from "../../lib/completion-target.js"; +import { ORG_PROJECT_TARGET } from "../../lib/completion-target.js"; import { ContextError, ResolutionError, @@ -37,12 +39,17 @@ import { FRESH_FLAG, } from "../../lib/list-command.js"; import { logger } from "../../lib/logger.js"; +import { + type BoundArguments, + boundArguments, +} from "../../lib/positional-parse.js"; import { resolveLogProjectId, resolveOrgAndProject, resolveProjectBoundSlug, } from "../../lib/resolve-target.js"; import { RETENTION_DAYS } from "../../lib/retention.js"; +import { applySentryUrlContext } from "../../lib/sentry-url-parser.js"; import { buildLogsUrl } from "../../lib/sentry-web-urls.js"; import { setOrgProjectContext } from "../../lib/telemetry.js"; import { isAllDigits } from "../../lib/utils.js"; @@ -150,14 +157,14 @@ export type ResolvedLogTarget = { /** * Resolve the target org/project from the parsed arg. * - * @param parsed - Result of `parseOrgProjectArg` + * @param parsed - The parsed target argument * @param rawLogIds - Raw log IDs (used for usage hints; not yet validated) * @param cwd - Current working directory * @returns Resolved target, or null if resolution produced nothing * @throws {ContextError} If org-all mode is used (requires specific project) */ async function resolveTarget( - parsed: ReturnType, + parsed: ParsedOrgProject, rawLogIds: string[], cwd: string, ): Promise { @@ -200,6 +207,58 @@ async function resolveTarget( } } +/** The arguments of `log view`: the log IDs as typed, and the target. */ +type LogViewInput = ReturnType & { + readonly target: OrgProjectInput; +}; + +/** The target of `log view`, and its log IDs, validated or recovered. */ +type ResolvedLogs = ResolvedLogTarget & { readonly logIds: string[] }; + +/** The arguments of `log view`. */ +type LogViewArguments = BoundArguments; + +/** Parse `log view` arguments, without side effects. */ +function parseLogViewInput(args: readonly string[]): LogViewInput { + const input = parsePositionalArgs(args); + return { ...input, target: parseOrgProjectInput(input.targetArg) }; +} + +/** + * Resolve `log view` arguments: find the org and project, then validate + * (or recover) the log IDs in that project. + */ +async function resolveLogViewInput( + input: LogViewInput, + context: SentryContext, +): Promise { + if (input.target.baseUrl) { + applySentryUrlContext(input.target.baseUrl); + } + const cmdLog = logger.withTag("log.view"); + for (const warning of input.warnings) { + cmdLog.warn(warning); + } + if (input.suggestion) { + cmdLog.warn(input.suggestion); + } + const target = await resolveTarget( + input.target.parsed, + input.rawLogIds, + context.cwd, + ); + if (!target) { + throw new ContextError("Organization and project", USAGE_HINT); + } + // `log.warn` preserves emission order regardless of await order, and a + // throwing recovery (multi-match ResolutionError) exits via + // `Promise.all` with the first-thrown error. + const logIds = await Promise.all( + input.rawLogIds.map((raw) => validateAndRecoverLogId(raw, target)), + ); + return { ...target, logIds }; +} + /** * Format a list of log IDs as a markdown bullet list. * @@ -427,15 +486,14 @@ export const viewCommand = buildCommand({ : data.logs, }, parameters: { - positional: { - kind: "array", - parameter: { - placeholder: "org/project/log-id", - brief: - "[/] [...] - Target (optional) and one or more log IDs", - parse: parseOrgProjectTarget, - }, - }, + positional: boundArguments({ + placeholder: "org/project/log-id", + brief: + "[/] [...] - Target (optional) and one or more log IDs", + kind: ORG_PROJECT_TARGET, + parse: parseLogViewInput, + resolve: resolveLogViewInput, + }), flags: { web: { kind: "boolean", @@ -446,36 +504,10 @@ export const viewCommand = buildCommand({ }, aliases: { ...FRESH_ALIASES, w: "web" }, }, - async *func(this: SentryContext, flags: ViewFlags, ...args: string[]) { + async *func(this: SentryContext, flags: ViewFlags, args: LogViewArguments) { applyFreshFlag(flags); - const { cwd } = this; const cmdLog = logger.withTag("log.view"); - - // Parse positional args (raw — validation is deferred to after target - // resolution so we can run fuzzy recovery with org/project context). - const { rawLogIds, targetArg, warnings, suggestion } = - parsePositionalArgs(args); - for (const warning of warnings) { - cmdLog.warn(warning); - } - if (suggestion) { - cmdLog.warn(suggestion); - } - const parsed = parseOrgProjectArg(targetArg); - - const target = await resolveTarget(parsed, rawLogIds, cwd); - - if (!target) { - throw new ContextError("Organization and project", USAGE_HINT); - } - - // Validate + recover each log ID in parallel. `log.warn` preserves - // emission order regardless of await order, and a throwing recovery - // (multi-match ResolutionError) exits via `Promise.all` with the - // first-thrown error — same UX as the old sequential loop. - const logIds = await Promise.all( - rawLogIds.map((raw) => validateAndRecoverLogId(raw, target)), - ); + const { logIds, ...target } = await args.resolved; if (flags.web) { await handleWebOpen(target.org, logIds); diff --git a/packages/cli/src/commands/span/view.ts b/packages/cli/src/commands/span/view.ts index d5db14264..30aff9040 100644 --- a/packages/cli/src/commands/span/view.ts +++ b/packages/cli/src/commands/span/view.ts @@ -13,7 +13,7 @@ import { } from "../../lib/api-client.js"; import { spansFlag } from "../../lib/arg-parsing.js"; import { buildCommand } from "../../lib/command.js"; -import { parseOrgProjectTarget } from "../../lib/completion-target.js"; +import { ORG_PROJECT_TARGET } from "../../lib/completion-target.js"; import { ContextError, ResolutionError, @@ -41,8 +41,15 @@ import { } from "../../lib/list-command.js"; import { logger } from "../../lib/logger.js"; import { - parseTraceTargetWithRecovery, + type BoundArguments, + boundArguments, +} from "../../lib/positional-parse.js"; +import { + parseTraceArgumentsInput, + type ResolvedTraceOrgProject, + resolveTraceArguments, resolveTraceOrgProject, + type TraceArgumentsInput, warnIfNormalized, } from "../../lib/trace-target.js"; @@ -59,15 +66,23 @@ type ViewFlags = { const USAGE_HINT = "sentry span view [//] [...]"; -/** The positional args of `span view`, parsed but not validated. */ -type SpanViewArgs = { - /** The trace: `[//]`, validated (or recovered) later */ - traceArg: string; - rawSpanIds: string[]; +/** The arguments of `span view`, parsed but with the IDs as typed. */ +export type SpanViewInput = { + /** The trace, validated (or recovered) when the arguments resolve */ + readonly trace: TraceArgumentsInput; + readonly rawSpanIds: readonly string[]; +}; + +/** The trace and the span IDs of `span view`, validated or recovered. */ +type ResolvedSpans = ResolvedTraceOrgProject & { + readonly spanIds: string[]; }; +/** The arguments of `span view`. */ +type SpanViewArguments = BoundArguments; + /** - * Parse positional arguments for span view. + * Parse `span view` arguments, without side effects. * * Span IDs take the forms every ID-taking command does (see * `id-arguments.ts`), with the trace as their target: @@ -75,13 +90,13 @@ type SpanViewArgs = { * `[//]/` in one argument (as `span list` * prints them), with more span IDs after it. * - * IDs are returned raw: the command validates the trace with - * {@link parseTraceTargetWithRecovery} and the spans once it knows the - * trace, so malformed IDs can be recovered. + * IDs are kept as typed: {@link resolveSpanViewInput} validates the trace + * and then the spans once it knows the trace, so malformed IDs can be + * recovered. * * @throws {ContextError} If the trace or the span IDs are missing */ -export function parsePositionalArgs(rawArgs: readonly string[]): SpanViewArgs { +export function parseSpanViewInput(rawArgs: readonly string[]): SpanViewInput { // A lone argument that does not end in a span ID is the trace const { target, ids } = parseIdArguments(rawArgs, ID_KINDS.span, { usageHint: USAGE_HINT, @@ -114,23 +129,20 @@ export function parsePositionalArgs(rawArgs: readonly string[]): SpanViewArgs { ]); } - requireTraceId(target, first); - return { traceArg: target, rawSpanIds: [...ids] }; + return { trace: parseTrace(target, first), rawSpanIds: ids }; } /** - * Throw a trace-focused error when the target of the span IDs has no trace - * ID, as in `//` (CLI-1GP). A malformed trace ID is - * left for {@link parseTraceTargetWithRecovery}. + * Parse the target of the span IDs as the trace, with a trace-focused error + * when it has no trace ID, as in `//` (CLI-1GP). A + * malformed trace ID is left for {@link resolveTraceArguments}. * * @throws {ContextError} When the trace ID is missing */ -function requireTraceId(traceArg: string, spanId: string): void { +function parseTrace(traceArg: string, spanId: string): TraceArgumentsInput { try { - parseIdArguments([traceArg], ID_KINDS.trace, { - usageHint: USAGE_HINT, + return parseTraceArgumentsInput([traceArg], USAGE_HINT, { needsProject: true, - validate: false, }); } catch (error) { if (!(error instanceof ContextError)) { @@ -143,6 +155,32 @@ function requireTraceId(traceArg: string, spanId: string): void { } } +/** + * Resolve `span view` arguments: validate (or recover) the trace ID, find + * the trace's org and project, then validate (or recover) the span IDs in + * that trace. + */ +async function resolveSpanViewInput( + input: SpanViewInput, + context: SentryContext, +): Promise { + const traceTarget = await resolveTraceArguments(input.trace); + warnIfNormalized(traceTarget, "span.view"); + const { traceId, org, project } = await resolveTraceOrgProject( + traceTarget, + context.cwd, + USAGE_HINT, + ); + // Span IDs are unique only within a trace, so recovery scopes its fuzzy + // lookup via `ctx.traceId`. + const spanIds = await Promise.all( + input.rawSpanIds.map((raw) => + validateAndRecoverSpanId(raw, { org, project, traceId }), + ), + ); + return { traceId, org, project, spanIds }; +} + /** * Validate a raw span ID, attempting {@link recoverHexId} on * {@link ValidationError}. Requires trace-scoped context because span IDs @@ -338,51 +376,28 @@ export const viewCommand = buildCommand({ jsonTransform: jsonTransformSpanView, }, parameters: { - positional: { - kind: "array", - parameter: { - placeholder: "trace-id/span-id", - brief: - "[//] [...] - Trace ID and one or more span IDs", - parse: parseOrgProjectTarget, - }, - }, + positional: boundArguments({ + placeholder: "trace-id/span-id", + brief: + "[//] [...] - Trace ID and one or more span IDs", + kind: ORG_PROJECT_TARGET, + parse: parseSpanViewInput, + resolve: resolveSpanViewInput, + }), flags: { ...spansFlag, fresh: FRESH_FLAG, }, aliases: { ...FRESH_ALIASES }, }, - async *func(this: SentryContext, flags: ViewFlags, ...args: string[]) { + async *func( + this: SentryContext, + flags: ViewFlags, + target: SpanViewArguments, + ) { applyFreshFlag(flags); - const { cwd } = this; - - // Parse positional args; the trace ID goes through - // `parseTraceTargetWithRecovery` for async trace-ID recovery — same - // contract as `trace view`. - const { traceArg, rawSpanIds } = parsePositionalArgs(args); - const traceTarget = await parseTraceTargetWithRecovery( - [traceArg], - USAGE_HINT, - { needsProject: true }, - ); - warnIfNormalized(traceTarget, "span.view"); + const { traceId, org, project, spanIds } = await target.resolved; - // Resolve org/project - const { traceId, org, project } = await resolveTraceOrgProject( - traceTarget, - cwd, - USAGE_HINT, - ); - - // Validate + recover span IDs now that trace context is available. - // Span IDs are unique only within a trace, so recovery scopes its - // fuzzy lookup via `ctx.traceId`. - const spanIds = await Promise.all( - rawSpanIds.map((raw) => - validateAndRecoverSpanId(raw, { org, project, traceId }), - ), - ); // Fetch trace data (single fetch for all span lookups) const spans = await getDetailedTrace(org, traceId); diff --git a/packages/cli/src/commands/trace/view.ts b/packages/cli/src/commands/trace/view.ts index 42183d9c8..a256a927a 100644 --- a/packages/cli/src/commands/trace/view.ts +++ b/packages/cli/src/commands/trace/view.ts @@ -17,7 +17,7 @@ import { import { looksLikeIssueShortId, spansFlag } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; -import { parseOrgProjectTarget } from "../../lib/completion-target.js"; +import { ORG_PROJECT_TARGET } from "../../lib/completion-target.js"; import { ContextError, ResolutionError, @@ -40,12 +40,18 @@ import { FRESH_FLAG, } from "../../lib/list-command.js"; import { logger } from "../../lib/logger.js"; +import { + type BoundArguments, + boundArguments, +} from "../../lib/positional-parse.js"; import { resolveOrg, toNumericId } from "../../lib/resolve-target.js"; import { buildTraceUrl } from "../../lib/sentry-web-urls.js"; import { setOrgProjectContext } from "../../lib/telemetry.js"; import { - parseTraceTargetWithRecovery, + parseTraceArgumentsInput, + resolveTraceArguments, resolveTraceOrgOptionalProject, + type TraceArgumentsInput, warnIfNormalized, } from "../../lib/trace-target.js"; import type { TraceSpan } from "../../types/index.js"; @@ -83,29 +89,71 @@ function buildViewHint( } /** - * Detect issue short IDs in raw positional args before trace-target parsing. - * Reversed arguments (` /`) need nothing here: the - * trace target's parser takes them, like every ID-taking command. + * The arguments of `trace view`, parsed: an issue short ID to find the + * trace of, or the trace ID in the forms every ID-taking command takes + * (see `id-arguments.ts`), as typed until it resolves. + */ +export type TraceViewInput = + | { readonly issueShortId: string } + | { + readonly trace: TraceArgumentsInput; + /** Suggestion when the first argument looks like an issue short ID */ + readonly suggestion?: string; + }; + +/** + * Parse `trace view` arguments, without side effects. * - * - **Single-arg issue short ID**: first arg looks like `CAM-82X` with no - * second arg → sets `issueShortId` for auto-recovery (resolve issue → trace). - * - **Two-arg issue short ID**: first arg looks like `CAM-82X` with a second - * arg → suggests `sentry issue view` (ambiguous intent, no auto-recovery). + * - **Single-arg issue short ID**: `CAM-82X` alone is looked up as an issue + * (resolve issue → trace). + * - **Two-arg issue short ID**: `CAM-82X` with a second argument suggests + * `sentry issue view` (ambiguous intent, no lookup). * * @internal Exported for testing */ -export function preProcessArgs(args: string[]): { - suggestion?: string; - /** Issue short ID detected for auto-recovery (single-arg only) */ - issueShortId?: string; -} { +export function parseTraceViewInput(args: readonly string[]): TraceViewInput { const [first] = args; - if (!(first && looksLikeIssueShortId(first))) { - return {}; + const shortId = first !== undefined && looksLikeIssueShortId(first); + if (shortId && args.length === 1) { + return { issueShortId: first }; } - return args.length === 1 - ? { issueShortId: first } - : { suggestion: `Did you mean: sentry issue view ${first}` }; + return { + trace: parseTraceArgumentsInput(args, USAGE_HINT), + ...(shortId && { suggestion: `Did you mean: sentry issue view ${first}` }), + }; +} + +/** The arguments of `trace view`. */ +type TraceViewArguments = BoundArguments; + +/** + * Resolve `trace view` arguments to the trace and the org (and project) to + * fetch it from, recovering a malformed trace ID. + */ +async function resolveTraceViewInput( + input: TraceViewInput, + context: SentryContext, +): Promise { + if ("issueShortId" in input) { + return await resolveTraceFromIssue(input.issueShortId, context.cwd); + } + if (input.suggestion) { + logger.withTag("trace.view").warn(input.suggestion); + } + const parsed = await resolveTraceArguments(input.trace); + warnIfNormalized(parsed, "trace.view"); + const target = await resolveTraceOrgOptionalProject( + parsed, + context.cwd, + USAGE_HINT, + ); + return { + ...target, + projectFilter: + parsed.type === "explicit" + ? (target as { project?: string }).project + : undefined, + }; } /** @@ -449,15 +497,14 @@ export const viewCommand = buildCommand({ jsonTransform: jsonTransformTraceView, }, parameters: { - positional: { - kind: "array", - parameter: { - placeholder: "org/project/trace-id", - brief: - "[//] - Target (optional) and trace ID (required)", - parse: parseOrgProjectTarget, - }, - }, + positional: boundArguments({ + placeholder: "org/project/trace-id", + brief: + "[//] - Target (optional) and trace ID (required)", + kind: ORG_PROJECT_TARGET, + parse: parseTraceViewInput, + resolve: resolveTraceViewInput, + }), flags: { web: { kind: "boolean", @@ -474,37 +521,13 @@ export const viewCommand = buildCommand({ }, aliases: { ...FRESH_ALIASES, w: "web" }, }, - async *func(this: SentryContext, flags: ViewFlags, ...args: string[]) { + async *func( + this: SentryContext, + flags: ViewFlags, + target: TraceViewArguments, + ) { applyFreshFlag(flags); - const { cwd } = this; - const log = logger.withTag("trace.view"); - - // Pre-process: detect issue short IDs - const { suggestion, issueShortId } = preProcessArgs(args); - if (suggestion) { - log.warn(suggestion); - } - - let resolved: ResolvedTrace; - if (issueShortId) { - resolved = await resolveTraceFromIssue(issueShortId, cwd); - } else { - const parsed = await parseTraceTargetWithRecovery(args, USAGE_HINT); - warnIfNormalized(parsed, "trace.view"); - const target = await resolveTraceOrgOptionalProject( - parsed, - cwd, - USAGE_HINT, - ); - resolved = { - ...target, - projectFilter: - parsed.type === "explicit" - ? (target as { project?: string }).project - : undefined, - }; - } - const { traceId, org, project, projectFilter } = resolved; + const { traceId, org, project, projectFilter } = await target.resolved; if (flags.web) { await openInBrowser(buildTraceUrl(org, traceId), "trace"); diff --git a/packages/cli/src/lib/trace-target.ts b/packages/cli/src/lib/trace-target.ts index a9c2c70d4..ab0efd3b7 100644 --- a/packages/cli/src/lib/trace-target.ts +++ b/packages/cli/src/lib/trace-target.ts @@ -240,10 +240,44 @@ function orgProjectToTraceTarget( } /** - * Parse trace-target args, falling back to {@link recoverHexId} on an - * invalid trace ID. Returns a {@link ParsedTraceTarget} with a validated - * or recovered trace ID, or throws the original {@link ValidationError} - * when recovery can't proceed. + * Trace arguments parsed like {@link parseTraceTargetInput}, but with the + * trace ID as typed: {@link resolveTraceArguments} validates it, or + * recovers a malformed one, when the command runs. + */ +export type TraceArgumentsInput = TraceTargetInput & { + /** The target argument as typed, for the recovery lookup */ + readonly targetArg?: string; +}; + +/** + * Parse trace arguments without side effects and without validating the + * trace ID, so a malformed one can be recovered once the command runs. + * + * @throws {ContextError} When the trace ID is missing + * @throws {ValidationError} When the target is invalid + */ +export function parseTraceArgumentsInput( + args: readonly string[], + usageHint: string, + options: TraceArgumentsOptions = {}, +): TraceArgumentsInput { + const { id, ...rest } = parseIdArgument(args, ID_KINDS.trace, { + usageHint, + ...options, + validate: false, + }); + return { + ...traceTargetInput({ ...rest, ids: [id] }), + targetArg: rest.target, + }; +} + +/** + * Apply what {@link parseTraceArgumentsInput} deferred and validate the + * trace ID, falling back to {@link recoverHexId} on an invalid one. + * Returns a {@link ParsedTraceTarget} with a validated or recovered trace + * ID, or throws the original {@link ValidationError} when recovery can't + * proceed. * * The recovery path runs the cheap classifications (sentinel, slug, etc.) * locally regardless of context. Fuzzy prefix lookup additionally requires @@ -251,46 +285,18 @@ function orgProjectToTraceTarget( * NOT attempt DSN/config auto-detection during recovery (it's expensive * and the adapter returns empty without a project anyway). */ -export async function parseTraceTargetWithRecovery( - args: readonly string[], - usageHint: string, - options: TraceArgumentsOptions = {}, +export async function resolveTraceArguments( + input: TraceArgumentsInput, ): Promise { + const parsed = applyTraceTargetInput(input); try { - return parseTraceTarget(args, usageHint, options); + return { ...parsed, traceId: ID_KINDS.trace.validate(parsed.traceId) }; } catch (err) { if (!(err instanceof ValidationError)) { throw err; } - const raw = rawIdArguments(args, { usageHint, ...options }, err); - const rawTraceId = raw.ids[0]; - if (!rawTraceId) { - throw err; - } - const recovered = await recoverTraceId(rawTraceId, raw.target, err); - return applyTraceTargetInput( - traceTargetInput({ ...raw, ids: [recovered] }), - ); - } -} - -/** - * The trace ID arguments as typed, to recover the ID from; `failure` when - * even they do not parse (an invalid path, say). - */ -function rawIdArguments( - args: readonly string[], - options: TraceArgumentsOptions & { readonly usageHint: string }, - failure: ValidationError, -): IdArguments { - try { - return parseIdArguments(args, ID_KINDS.trace, { - ...options, - validate: false, - }); - } catch (error) { - log.debug("Trace arguments do not parse without validation", error); - throw failure; + const traceId = await recoverTraceId(parsed.traceId, input.targetArg, err); + return { ...parsed, traceId }; } } diff --git a/packages/cli/test/commands/span/view.test.ts b/packages/cli/test/commands/span/view.test.ts index 3ad4928c8..87bbf8dd8 100644 --- a/packages/cli/test/commands/span/view.test.ts +++ b/packages/cli/test/commands/span/view.test.ts @@ -5,9 +5,10 @@ * and output formatting in src/commands/span/view.ts. */ +import { ArgumentParseError } from "@stricli/core"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { - parsePositionalArgs, + parseSpanViewInput, viewCommand, } from "../../../src/commands/span/view.js"; @@ -46,6 +47,7 @@ vi.mock("../../../src/lib/resolve-target.js", async (importOriginal) => { // oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for spyOn mocking import * as resolveTarget from "../../../src/lib/resolve-target.js"; +import { parseTraceArgumentsInput } from "../../../src/lib/trace-target.js"; const VALID_TRACE_ID = "aaaa1111bbbb2222cccc3333dddd4444"; const VALID_SPAN_ID = "a1b2c3d4e5f67890"; @@ -92,7 +94,10 @@ describe("validateSpanId", () => { }); }); -describe("parsePositionalArgs", () => { +describe("parseSpanViewInput", () => { + /** The trace of `traceArg`, as `span view` parses it */ + const trace = (traceArg: string) => + parseTraceArgumentsInput([traceArg], "", { needsProject: true }); const SLASH_FORM = `my-org/my-project/${VALID_TRACE_ID}`; test.each([ @@ -134,47 +139,48 @@ describe("parsePositionalArgs", () => { [VALID_SPAN_ID, VALID_SPAN_ID_2], ], ])("%j → trace %j, spans %j", (args, traceArg, rawSpanIds) => { - expect(parsePositionalArgs(args)).toEqual({ traceArg, rawSpanIds }); + expect(parseSpanViewInput(args)).toEqual({ + trace: trace(traceArg), + rawSpanIds, + }); }); test("keeps the case of IDs for the command to normalize", () => { expect( - parsePositionalArgs([ - "AAAA1111BBBB2222CCCC3333DDDD4444/A1B2C3D4E5F67890", - ]), + parseSpanViewInput(["AAAA1111BBBB2222CCCC3333DDDD4444/A1B2C3D4E5F67890"]), ).toEqual({ - traceArg: "AAAA1111BBBB2222CCCC3333DDDD4444", + trace: trace("AAAA1111BBBB2222CCCC3333DDDD4444"), rawSpanIds: ["A1B2C3D4E5F67890"], }); }); test("leaves malformed IDs raw, for recovery at the command layer", () => { - expect(parsePositionalArgs(["not-valid", VALID_SPAN_ID])).toEqual({ - traceArg: "not-valid", + expect(parseSpanViewInput(["not-valid", VALID_SPAN_ID])).toEqual({ + trace: trace("not-valid"), rawSpanIds: [VALID_SPAN_ID], }); - expect(parsePositionalArgs([`not-a-hex-id/${VALID_SPAN_ID}`])).toEqual({ - traceArg: "not-a-hex-id", + expect(parseSpanViewInput([`not-a-hex-id/${VALID_SPAN_ID}`])).toEqual({ + trace: trace("not-a-hex-id"), rawSpanIds: [VALID_SPAN_ID], }); expect( - parsePositionalArgs([`my-org/my-project/not-a-trace/${VALID_SPAN_ID}`]), + parseSpanViewInput([`my-org/my-project/not-a-trace/${VALID_SPAN_ID}`]), ).toEqual({ - traceArg: "my-org/my-project/not-a-trace", + trace: trace("my-org/my-project/not-a-trace"), rawSpanIds: [VALID_SPAN_ID], }); - expect(parsePositionalArgs([VALID_TRACE_ID, "not-a-span-id"])).toEqual({ - traceArg: VALID_TRACE_ID, + expect(parseSpanViewInput([VALID_TRACE_ID, "not-a-span-id"])).toEqual({ + trace: trace(VALID_TRACE_ID), rawSpanIds: ["not-a-span-id"], }); - expect( - parsePositionalArgs([VALID_TRACE_ID, "abcd1234"]).rawSpanIds, - ).toEqual(["abcd1234"]); + expect(parseSpanViewInput([VALID_TRACE_ID, "abcd1234"]).rawSpanIds).toEqual( + ["abcd1234"], + ); }); test("org/project/spanId (missing trace ID) throws a trace-focused ContextError (CLI-1GP)", () => { try { - parsePositionalArgs([`my-org/my-project/${VALID_SPAN_ID}`]); + parseSpanViewInput([`my-org/my-project/${VALID_SPAN_ID}`]); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ContextError); @@ -195,26 +201,26 @@ describe("parsePositionalArgs", () => { [[` ${VALID_TRACE_ID} `]], [[`${VALID_TRACE_ID}/bbbb2222cccc3333dddd4444eeee5555`]], ])("%j → ContextError", (args) => { - expect(() => parsePositionalArgs(args)).toThrow(ContextError); + expect(() => parseSpanViewInput(args)).toThrow(ContextError); }); test("error message mentions trace ID and span ID", () => { - expect(() => parsePositionalArgs([])).toThrow("Trace ID"); + expect(() => parseSpanViewInput([])).toThrow("Trace ID"); }); test("missing span IDs error suggests span list", () => { - expect(() => parsePositionalArgs([VALID_TRACE_ID])).toThrow("span list"); + expect(() => parseSpanViewInput([VALID_TRACE_ID])).toThrow("span list"); }); test("throws ContextError for bare span ID without trace ID (CLI-SC)", () => { - expect(() => parsePositionalArgs(["a1b2c3d4e5f67890"])).toThrow( + expect(() => parseSpanViewInput(["a1b2c3d4e5f67890"])).toThrow( ContextError, ); }); test("bare span ID error identifies the input and suggests correct usage", () => { try { - parsePositionalArgs(["A1B2C3D4E5F67890"]); + parseSpanViewInput(["A1B2C3D4E5F67890"]); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ContextError); @@ -227,7 +233,7 @@ describe("parsePositionalArgs", () => { test("bare span ID with dashes is still detected (CLI-SC)", () => { // Some tools format span IDs with dashes - expect(() => parsePositionalArgs(["a1b2-c3d4-e5f6-7890"])).toThrow( + expect(() => parseSpanViewInput(["a1b2-c3d4-e5f6-7890"])).toThrow( "looks like a span ID", ); }); @@ -449,18 +455,21 @@ describe("viewCommand.func", () => { expect(parsed[0].ancestors).toEqual([]); }); - test("throws ContextError for org-all target (org/ without project)", async () => { + test("fails to parse without span IDs, before resolving", async () => { const { context } = createContext(); - // "my-org/" is parsed as org-all mode which is not supported for span view - await expect( - func.call( + const error = await func + .call( context, { spans: 0, fresh: false }, `my-org/my-project/${VALID_TRACE_ID}`, - // No span IDs — but we need at least one - ), - ).rejects.toThrow(ContextError); + ) + .catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(ArgumentParseError); + expect((error as ArgumentParseError).exception).toBeInstanceOf( + ContextError, + ); + expect(getDetailedTraceSpy).not.toHaveBeenCalled(); }); test("throws ResolutionError for multiple missing span IDs", async () => { diff --git a/packages/cli/test/commands/trace/view.func.test.ts b/packages/cli/test/commands/trace/view.func.test.ts index b5652c3ff..20c35834b 100644 --- a/packages/cli/test/commands/trace/view.func.test.ts +++ b/packages/cli/test/commands/trace/view.func.test.ts @@ -8,6 +8,7 @@ * the func() body without real HTTP calls or database access. */ +import { ArgumentParseError } from "@stricli/core"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { flattenSpanTree, @@ -649,6 +650,39 @@ describe("viewCommand.func", () => { ).rejects.toThrow(ContextError); }); + test("a missing trace ID fails to parse, before any lookup", async () => { + const { context } = createMockContext(); + const func = await viewCommand.loader(); + + const error = await func + .call(context, { json: false, web: false, spans: 100 }, "my-org/web") + .catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(ArgumentParseError); + expect((error as ArgumentParseError).exception).toBeInstanceOf( + ContextError, + ); + expect(resolveOrgAndProjectSpy).not.toHaveBeenCalled(); + expect(getDetailedTraceSpy).not.toHaveBeenCalled(); + }); + + test("recovers a malformed trace ID when it resolves", async () => { + getDetailedTraceSpy.mockResolvedValue(sampleSpans); + + const { context } = createMockContext(); + const func = await viewCommand.loader(); + await func.call( + context, + { json: true, web: false, spans: 100 }, + "my-org/aaaa1111bbbb2222cccc3333dddd4444ios", + ); + + expect(getDetailedTraceSpy).toHaveBeenCalledWith( + "my-org", + "aaaa1111bbbb2222cccc3333dddd4444", + expect.anything(), + ); + }); + test("resolves project search target", async () => { findProjectsBySlugSpy.mockResolvedValue({ projects: [ diff --git a/packages/cli/test/commands/trace/view.test.ts b/packages/cli/test/commands/trace/view.test.ts index 520b2f825..2953d5bbc 100644 --- a/packages/cli/test/commands/trace/view.test.ts +++ b/packages/cli/test/commands/trace/view.test.ts @@ -1,14 +1,14 @@ /** * Trace View Command Tests * - * Tests for pre-processing (swap detection, issue ID detection) - * and project resolution in src/commands/trace/view.ts. + * Tests for argument parsing (issue ID detection) and project resolution + * in src/commands/trace/view.ts. * * Note: Core trace target parsing tests are in test/lib/trace-target.test.ts. */ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { preProcessArgs } from "../../../src/commands/trace/view.js"; +import { parseTraceViewInput } from "../../../src/commands/trace/view.js"; import type { ProjectWithOrg } from "../../../src/lib/api-client.js"; vi.mock("../../../src/lib/api-client.js", async (importOriginal) => { @@ -24,36 +24,60 @@ vi.mock("../../../src/lib/api-client.js", async (importOriginal) => { // oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for spyOn mocking import * as apiClient from "../../../src/lib/api-client.js"; -import { ResolutionError, ValidationError } from "../../../src/lib/errors.js"; +import { + ContextError, + ResolutionError, + ValidationError, +} from "../../../src/lib/errors.js"; import { resolveProjectBoundSlug } from "../../../src/lib/resolve-target.js"; const VALID_TRACE_ID = "aaaa1111bbbb2222cccc3333dddd4444"; -describe("preProcessArgs", () => { - test("leaves trace arguments alone", () => { - expect(preProcessArgs([VALID_TRACE_ID])).toEqual({}); - expect(preProcessArgs(["my-org/frontend", VALID_TRACE_ID])).toEqual({}); - // Reversed arguments are the trace target parser's - expect(preProcessArgs([VALID_TRACE_ID, "my-org/frontend"])).toEqual({}); - expect(preProcessArgs([])).toEqual({}); +describe("parseTraceViewInput", () => { + test("parses trace arguments, keeping the trace ID as typed", () => { + expect(parseTraceViewInput([VALID_TRACE_ID])).toEqual({ + trace: { + parsed: { type: "auto-detect", traceId: VALID_TRACE_ID }, + warnings: [], + }, + }); + expect(parseTraceViewInput(["my-org/frontend", "not-a-trace"])).toEqual({ + trace: { + parsed: { + type: "explicit", + org: "my-org", + project: "frontend", + traceId: "not-a-trace", + }, + targetArg: "my-org/frontend", + warnings: [], + }, + }); }); - test("detects issue short ID and suggests issue view (two args)", () => { - const result = preProcessArgs(["CAM-82X", VALID_TRACE_ID]); - expect(result.suggestion).toContain("sentry issue view CAM-82X"); - expect(result.issueShortId).toBeUndefined(); + test("a missing trace ID fails to parse", () => { + expect(() => parseTraceViewInput([])).toThrow(ContextError); + expect(() => parseTraceViewInput(["my-org/frontend"])).toThrow( + ContextError, + ); }); - test("detects single-arg issue short ID for auto-recovery", () => { - const result = preProcessArgs(["CLI-G5"]); - expect(result.issueShortId).toBe("CLI-G5"); - expect(result.suggestion).toBeUndefined(); + test("detects issue short ID and suggests issue view (two args)", () => { + const result = parseTraceViewInput(["CAM-82X", VALID_TRACE_ID]); + expect(result).toMatchObject({ + suggestion: "Did you mean: sentry issue view CAM-82X", + }); + expect(result).not.toHaveProperty("issueShortId"); }); - test("detects multi-segment issue short ID for auto-recovery", () => { - const result = preProcessArgs(["SPOTLIGHT-ELECTRON-4D"]); - expect(result.issueShortId).toBe("SPOTLIGHT-ELECTRON-4D"); - }); + test.each(["CLI-G5", "SPOTLIGHT-ELECTRON-4D"])( + "a lone issue short ID %j is looked up as an issue", + (shortId) => { + expect(parseTraceViewInput([shortId])).toEqual({ + issueShortId: shortId, + }); + }, + ); }); describe("resolveProjectBoundSlug", () => { diff --git a/packages/cli/test/lib/trace-target.test.ts b/packages/cli/test/lib/trace-target.test.ts index 3d0fb3562..20564c04d 100644 --- a/packages/cli/test/lib/trace-target.test.ts +++ b/packages/cli/test/lib/trace-target.test.ts @@ -41,8 +41,9 @@ import { parseDualModeArgs, parseDualModeInput, parseTraceTarget, + parseTraceArgumentsInput, parseTraceTargetInput, - parseTraceTargetWithRecovery, + resolveTraceArguments, resolveTraceOrg, resolveTraceOrgOptionalProject, resolveTraceOrgProject, @@ -446,8 +447,10 @@ describe("parsing without side effects", () => { }); }); -describe("parseTraceTargetWithRecovery", () => { +describe("trace arguments, recovering a malformed trace ID", () => { const HINT = "sentry trace view [/[/]]"; + const resolve = (args: readonly string[]) => + resolveTraceArguments(parseTraceArgumentsInput(args, HINT)); beforeEach(() => { vi.spyOn(region, "resolveEffectiveOrg").mockImplementation( @@ -459,10 +462,28 @@ describe("parseTraceTargetWithRecovery", () => { vi.mocked(region.resolveEffectiveOrg).mockRestore(); }); + test("parsing keeps the trace ID as typed", () => { + expect(parseTraceArgumentsInput(["acme/web", "nothex"], HINT)).toEqual({ + parsed: { + type: "explicit", + org: "acme", + project: "web", + traceId: "nothex", + }, + targetArg: "acme/web", + warnings: [], + }); + }); + + test("resolving normalizes a valid trace ID", async () => { + await expect(resolve([VALID_TRACE_ID.toUpperCase()])).resolves.toEqual({ + type: "auto-detect", + traceId: VALID_TRACE_ID, + }); + }); + test("recovers a malformed trace ID after the org", async () => { - await expect( - parseTraceTargetWithRecovery([`acme/${VALID_TRACE_ID}xyz`], HINT), - ).resolves.toEqual({ + await expect(resolve([`acme/${VALID_TRACE_ID}xyz`])).resolves.toEqual({ type: "org-scoped", traceId: VALID_TRACE_ID, org: "acme", @@ -473,18 +494,16 @@ describe("parseTraceTargetWithRecovery", () => { // Without a project there is nothing to look the prefix up in, so // recovery finds no match, rather than the trace ID being missing. const prefix = VALID_TRACE_ID.slice(0, 12); - await expect( - parseTraceTargetWithRecovery([`acme/${prefix}`], HINT), - ).rejects.toMatchObject({ + await expect(resolve([`acme/${prefix}`])).rejects.toMatchObject({ constructor: ResolutionError, resource: `Trace '${prefix}'`, headline: "not found", }); }); - test("/ is still missing the trace ID", async () => { - await expect( - parseTraceTargetWithRecovery(["acme/web"], HINT), - ).rejects.toThrow(ContextError); + test("/ is still missing the trace ID", () => { + expect(() => parseTraceArgumentsInput(["acme/web"], HINT)).toThrow( + ContextError, + ); }); }); From 099a650d6a6b49131f8287cd63e68f435b7f07dd Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:53:26 +0000 Subject: [PATCH 11/13] fix(cli): Recover a prefixed ID after the org `/trace-` (an ID copied with its URL fragment prefix) was read as `/`, because the slug check saw the prefix's dash and letters. Strip the `trace-`, `span-`, `event-` and `txn-` prefixes before deciding the segment is a slug, as recovery does, so it reaches recovery like a bare `trace-`. --- packages/cli/src/lib/hex-id-recovery.ts | 44 ++++++++++++---------- packages/cli/test/lib/id-arguments.test.ts | 3 ++ packages/cli/test/lib/trace-target.test.ts | 8 ++++ 3 files changed, 36 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/lib/hex-id-recovery.ts b/packages/cli/src/lib/hex-id-recovery.ts index e301c3827..5edf4c95d 100644 --- a/packages/cli/src/lib/hex-id-recovery.ts +++ b/packages/cli/src/lib/hex-id-recovery.ts @@ -196,6 +196,27 @@ const SLUG_REDIRECT_COMMAND: Record = { // Pure helpers (exported for tests) // --------------------------------------------------------------------------- +/** + * Strip URL fragment prefixes (`span-`, `trace-`, ...) from a lowercased + * input. Repeatedly, so it is idempotent: a pathological double-prefixed + * input like `span-span-abc` would otherwise keep its inner prefix. + */ +function stripUrlFragmentPrefixes(lowered: string): string { + let cleaned = lowered; + let stripped = true; + while (stripped) { + stripped = false; + for (const prefix of URL_FRAGMENT_PREFIXES) { + if (cleaned.startsWith(prefix)) { + cleaned = cleaned.slice(prefix.length); + stripped = true; + break; + } + } + } + return cleaned; +} + /** * Pre-normalize a raw input before further analysis. Pipeline: * @@ -216,24 +237,7 @@ export function preNormalize(input: string): { cleaned: string; sentinel?: string; } { - const lowered = input.trim().toLowerCase(); - - // Strip URL fragment prefixes repeatedly so the function is idempotent. - // A pathological double-prefixed input like `span-span-abc` would otherwise - // strip only the outer `span-` and leave the inner prefix in place, which - // breaks `preNormalize(preNormalize(x)) === preNormalize(x)`. - let cleaned = lowered; - let stripped = true; - while (stripped) { - stripped = false; - for (const prefix of URL_FRAGMENT_PREFIXES) { - if (cleaned.startsWith(prefix)) { - cleaned = cleaned.slice(prefix.length); - stripped = true; - break; - } - } - } + let cleaned = stripUrlFragmentPrefixes(input.trim().toLowerCase()); // Sentinel check runs AFTER URL-prefix stripping so inputs like // `span-null` (shell variable leak through a URL-builder) surface as @@ -351,7 +355,9 @@ export function looksLikeSlug(input: string): boolean { * project. */ export function looksLikeHexIdFragment(input: string): boolean { - if (looksLikeSlug(input)) { + // A URL fragment prefix (`trace-`) has a dash and letters, but does + // not make the input a slug + if (looksLikeSlug(stripUrlFragmentPrefixes(input.trim().toLowerCase()))) { return false; } const { cleaned, sentinel } = preNormalize(input); diff --git a/packages/cli/test/lib/id-arguments.test.ts b/packages/cli/test/lib/id-arguments.test.ts index bb70921f5..a333d582b 100644 --- a/packages/cli/test/lib/id-arguments.test.ts +++ b/packages/cli/test/lib/id-arguments.test.ts @@ -154,6 +154,9 @@ describe.each(KINDS)("%s IDs", (_name, kind, id, other) => { `${id.slice(0, 8)}...${id.slice(-8)}`, ], [`acme/${id}xyz`, `${id}xyz`], + // A URL fragment prefix, as in `#span-` + [`acme/trace-${id}`, `trace-${id}`], + [`acme/span-${id.slice(0, 12)}`, `span-${id.slice(0, 12)}`], ])("%j → invalid ID, or raw ID %j after the org", (arg, raw) => { expect(() => parse(kind, [arg])).toThrow(ValidationError); expect(parse(kind, [arg], { validate: false })).toEqual({ diff --git a/packages/cli/test/lib/trace-target.test.ts b/packages/cli/test/lib/trace-target.test.ts index 20564c04d..e2af2a222 100644 --- a/packages/cli/test/lib/trace-target.test.ts +++ b/packages/cli/test/lib/trace-target.test.ts @@ -490,6 +490,14 @@ describe("trace arguments, recovering a malformed trace ID", () => { }); }); + test("recovers a trace ID with a URL fragment prefix after the org", async () => { + await expect(resolve([`acme/trace-${VALID_TRACE_ID}`])).resolves.toEqual({ + type: "org-scoped", + traceId: VALID_TRACE_ID, + org: "acme", + }); + }); + test("a partial trace ID after the org reaches recovery", async () => { // Without a project there is nothing to look the prefix up in, so // recovery finds no match, rather than the trace ID being missing. From 07775e4600accf9cf6938b8ce71a9339f1d9befe Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:04:31 +0000 Subject: [PATCH 12/13] fix(cli): Look up a prefixed partial ID instead of calling it a slug Recovery's slug check ran on the input with its URL fragment prefix, so `trace-` or `span-...` failed as "looks like a project slug" and never reached the fuzzy lookup, though `/trace-` now gets to recovery. Share the prefix-stripping slug check between the grammar's ID hint and recovery. `span-my-app` is still a slug. --- packages/cli/src/lib/hex-id-recovery.ts | 21 ++++++++++------ packages/cli/test/lib/hex-id-recovery.test.ts | 25 +++++++++++++++++++ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/lib/hex-id-recovery.ts b/packages/cli/src/lib/hex-id-recovery.ts index 5edf4c95d..8dd9a95a0 100644 --- a/packages/cli/src/lib/hex-id-recovery.ts +++ b/packages/cli/src/lib/hex-id-recovery.ts @@ -347,6 +347,14 @@ export function looksLikeSlug(input: string): boolean { return !(PURE_HEX_RE.test(dashless) && dashless.length >= MIN_FUZZY_PREFIX); } +/** + * {@link looksLikeSlug} after URL fragment prefixes are stripped: a prefix + * (`trace-`) has a dash and letters, but does not make the input a slug. + */ +function looksLikeUnprefixedSlug(input: string): boolean { + return looksLikeSlug(stripUrlFragmentPrefixes(input.trim().toLowerCase())); +} + /** * Whether `input`, not a valid ID, still has enough of one for * {@link recoverHexId} to look it up: a hex prefix or ellipsized ID of at @@ -355,9 +363,7 @@ export function looksLikeSlug(input: string): boolean { * project. */ export function looksLikeHexIdFragment(input: string): boolean { - // A URL fragment prefix (`trace-`) has a dash and letters, but does - // not make the input a slug - if (looksLikeSlug(stripUrlFragmentPrefixes(input.trim().toLowerCase()))) { + if (looksLikeUnprefixedSlug(input)) { return false; } const { cleaned, sentinel } = preNormalize(input); @@ -816,11 +822,12 @@ async function recoverHexIdInternal( return redirect; } - // Slug-shape check on the RAW input before fuzzy lookup. Slug-like - // inputs where the dashless form happens to start with enough hex - // chars (e.g. `cafe-babe-app` → `cafebabeapp`) would otherwise reach + // Slug-shape check on the input (without URL fragment prefixes, but + // before partial-UUID dashes are stripped) before fuzzy lookup. + // Slug-like inputs where the dashless form happens to start with enough + // hex chars (e.g. `cafe-babe-app` → `cafebabeapp`) would otherwise reach // runFuzzyLookup and waste an API call on a clearly-mistaken input. - if (looksLikeSlug(input)) { + if (looksLikeUnprefixedSlug(input)) { return { kind: "failed", original: input, diff --git a/packages/cli/test/lib/hex-id-recovery.test.ts b/packages/cli/test/lib/hex-id-recovery.test.ts index 9c0146477..9c5391512 100644 --- a/packages/cli/test/lib/hex-id-recovery.test.ts +++ b/packages/cli/test/lib/hex-id-recovery.test.ts @@ -331,6 +331,31 @@ describe("recoverHexId decision tree", () => { expect(r.kind === "fuzzy" && r.prefix).toBe("05d6975ab9bb"); }); + test.each([ + ["trace-05d6975ab9bb", "05d6975ab9bb", undefined], + ["span-05d6975a...00000000", "05d6975a", "00000000"], + ])( + "fuzzy-looks up a prefixed partial ID %j", + async (input, prefix, suffix) => { + const fullId = `05d6975ab9bb${"0".repeat(20)}`; + stubAdapter("event", async () => [fullId]); + const r = await recoverHexId(input, "event", CLEAN_CTX); + expect(r).toMatchObject({ kind: "fuzzy", id: fullId, prefix }); + expect(r.kind === "fuzzy" && r.suffix).toBe(suffix); + }, + ); + + test("a prefixed slug is still a slug", async () => { + let called = false; + stubAdapter("event", async () => { + called = true; + return []; + }); + const r = await recoverHexId("span-my-app", "event", CLEAN_CTX); + expect(r).toMatchObject({ kind: "failed", reason: "looks-like-slug" }); + expect(called).toBe(false); + }); + test("fuzzy with multiple matches returns multiple-matches", async () => { const id1 = `05d6975ab9bb${"0".repeat(20)}`; const id2 = `05d6975ab9bb${"1".repeat(20)}`; From b5efd676bf4c34fd05b79ee55abe7afd81be1e03 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:28:31 +0000 Subject: [PATCH 13/13] test(cli): Keep partial-ID-shaped slugs out of the log view target property `/` is now read as a log ID to recover when `x` has enough hex for a fuzzy lookup, so a generated project slug like `aa000a00` no longer makes the one-slash property throw ContextError. Generate only project slugs that cannot be a partial ID there, and add a property that `/` parses as org target plus raw log ID. --- .../test/commands/log/view.property.test.ts | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/cli/test/commands/log/view.property.test.ts b/packages/cli/test/commands/log/view.property.test.ts index 2488c9cab..e4a44abb1 100644 --- a/packages/cli/test/commands/log/view.property.test.ts +++ b/packages/cli/test/commands/log/view.property.test.ts @@ -15,6 +15,7 @@ import { import { describe, expect, test } from "vitest"; import { parsePositionalArgs } from "../../../src/commands/log/view.js"; import { ContextError } from "../../../src/lib/errors.js"; +import { looksLikeHexIdFragment } from "../../../src/lib/hex-id-recovery.js"; import { DEFAULT_NUM_RUNS } from "../../model-based/helpers.js"; /** Valid log IDs (32-char hex) */ @@ -23,6 +24,16 @@ const logIdArb = stringMatching(/^[a-f0-9]{32}$/); /** Valid org/project slugs */ const slugArb = stringMatching(/^[a-z][a-z0-9-]{1,20}[a-z0-9]$/); +/** + * Project slugs that cannot be read as a partial log ID. `/` is + * parsed as an ID to recover when `x` has enough hex for a fuzzy lookup + * (e.g. `aa000a00`), so only these slugs make `/` a target. + */ +const projectSlugArb = slugArb.filter((s) => !looksLikeHexIdFragment(s)); + +/** Partial log IDs: a hex prefix long enough for a fuzzy lookup */ +const partialLogIdArb = stringMatching(/^[a-f0-9]{8,31}$/); + describe("parsePositionalArgs properties", () => { test("single valid log ID: returns it in rawLogIds with undefined targetArg", async () => { fcAssert( @@ -49,7 +60,7 @@ describe("parsePositionalArgs properties", () => { test("single arg with one slash: throws ContextError (missing log ID)", async () => { fcAssert( - property(tuple(slugArb, slugArb), ([org, project]) => { + property(tuple(slugArb, projectSlugArb), ([org, project]) => { expect(() => parsePositionalArgs([`${org}/${project}`])).toThrow( ContextError, ); @@ -58,6 +69,17 @@ describe("parsePositionalArgs properties", () => { ); }); + test("single arg org/partial-id: org is targetArg, partial ID is in rawLogIds", async () => { + fcAssert( + property(tuple(slugArb, partialLogIdArb), ([org, partialId]) => { + const result = parsePositionalArgs([`${org}/${partialId}`]); + expect(result.targetArg).toBe(org); + expect(result.rawLogIds).toEqual([partialId]); + }), + { numRuns: DEFAULT_NUM_RUNS }, + ); + }); + test("two args (target + logId): first is targetArg, second is in rawLogIds", async () => { fcAssert( property(tuple(slugArb, logIdArb), ([slug, logId]) => {