Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
69 changes: 59 additions & 10 deletions packages/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,18 +227,42 @@ export const myRoute = buildRouteMap({

### Positional Arguments

Use `parseSlashSeparatedArg` from `src/lib/arg-parsing.ts` for the standard `[<org>/<project>/]<id>` 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:

- `<id>`, `<org>/<id>`, `<org>/<project>/<id>`, `<target> <id>`, a Sentry URL
- `<id> <org>/<project>`, 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 `<x>/<id>` should name project `<x>`
rather than org `<x>`, `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 `[<org>/][<project>]` positional uses a
parser from `src/lib/target-positional.ts`, which binds the resolver:
Expand Down Expand Up @@ -267,9 +291,34 @@ 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
`[<org>/<project>/]<trace-id>`, 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 (`[<org>/<project>] <replay-id>`, `log list`'s optional
trailing trace ID), declare the positional with `boundArguments` from
`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<Parsed, Resolved>` (alias it per command and add the alias to
the lint rule's type list), reads `target.parsed`, and awaits
`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). 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
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`
Expand Down
60 changes: 60 additions & 0 deletions packages/cli/grammar/id-arguments.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// 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.

/** 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;
};

/**
* What the command parses: the grammar itself does not know any kind of ID.
* See `parseIdArguments()`.
*/
export type IdArgumentsGrammarOptions = {
/** 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;
/** 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;
};

/** A token that names an ID, maybe after a target: `[<target>/]<id>`. */
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 `<org>/` */
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 {
expected: unknown;
found: string | null;
location: unknown;
}

/** Parse NUL-prefixed tokens into a target and IDs. */
export declare function parse(
input: string,
options: IdArgumentsGrammarOptions,
): IdArgumentsSyntax;
117 changes: 117 additions & 0 deletions packages/cli/grammar/id-arguments.pegjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// 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:
//
// <id> the org/project is auto-detected
// <org>/<id> like a URL: the org, then the ID (the
// caller may read `<org>` as a project)
// <org>/<project>/<id>
// <target> <id> any org/project target, as everywhere else
// <id> <org>/<project> reversed; the caller warns
// <id-url> a Sentry URL with the ID in it
//
// More IDs may follow any of these, each repeating the target or not
// (`<target>/<id>`). 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) 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 { optional, isId, mayBeId, idUrl } = options;

// `[<org>/[<project>/]]` before an ID; the caller makes it a target
function pathTarget(prefix) {
if (prefix === "") {
return {};
}
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. Every action
// here only builds values, and the hooks are pure, so that is harmless.

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 }),
};
}
// Any other URL is a target
/ sep t:url x:more { return { target: t, ids: x }; }
// `<id> <org>/<project>`: 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 }; }
// `[<org>/[<project>/]]<id> [<id>...]`
/ sep p:idPath x:more {
return {
...pathTarget(p.target ?? ""),
ids: [{ text: p.text, id: p.id }, ...x],
};
}
// 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 (`<org>/<project>/<id>`), after one when it looks like a
// malformed ID (`<org>/<partial-id>`), or the whole token. Otherwise,
// `<org>/<project>` 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 {
...pathTarget(t.slice(0, slash)),
ids: [{ text: t, id: t.slice(slash + 1) }],
};
}
/ sep i:segment end &{ return !optional; } { return { ids: [{ text: i, id: i }] }; }
// `<target> [<id>...]`
/ sep t:token x:more { return { target: t, ids: x }; }
Comment thread
cursor[bot] marked this conversation as resolved.

// The IDs after the first token. Each may repeat the target:
// `<target>/<id>`.
more
= (sep @(idPath / t:token { return { text: t, id: t }; }))*

// `[<target>/]<id>` in one token: the last segment is an ID, maybe with
// slashes pasted after it.
idPath
= t:$(!("/" idSegment argEnd) [^\0])* "/" i:idSegment argEnd
{ return { text: text(), target: t, id: i }; }
/ i:idSegment argEnd { return { text: text(), id: i }; }

// An ID, and the slashes after it
idSegment
= 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]*)

token = $[^\0]+

segment = $[^/\0]+

sep = "\0"

argEnd = &sep / end

end = !.
67 changes: 66 additions & 1 deletion packages/cli/lint-rules/cli-oxlint-plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,20 @@ 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",
"LogViewArguments",
"ReplayArguments",
"SpanViewArguments",
"TraceViewArguments",
]);

function boundTargetParams(fn) {
Expand Down Expand Up @@ -141,6 +150,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.<kind>`. */
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.",
Expand Down Expand Up @@ -309,6 +347,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) =>
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
type ApplicationText,
ArgumentScannerError,
buildApplication,
text_en,
UnexpectedPositionalError,
Expand Down Expand Up @@ -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)) {
Expand Down
Loading
Loading