diff --git a/apps/cli-docs/src/fragments/commands/index.md b/apps/cli-docs/src/fragments/commands/index.md index 2e2830a1a..09430fd0e 100644 --- a/apps/cli-docs/src/fragments/commands/index.md +++ b/apps/cli-docs/src/fragments/commands/index.md @@ -17,10 +17,14 @@ Many commands take an optional `/` target as their first positiona | Target | Meaning | |--------|---------| | _(omitted)_ | Auto-detect the org and project from config, DSNs, or the directory name | +| `https://.sentry.io/...` | A Sentry web URL. Selects the org and project in the URL, or the whole organization when it names no project | | `/` | That project in that organization | | `/,` | Several projects in the same organization (commands that accept several projects) | +| `/` | A project in that organization whose display name matches. A target with a space is a name, not a slug | | `/` | The whole organization | | `` | A project named `` in any accessible organization. If no project matches, commands that accept an organization use the organization named ``. When both exist, the project wins: add the trailing slash (`/`) to select the organization | +| `/` | Same as `` | +| `` | A project whose display name matches, in any accessible organization | ## JSON Output diff --git a/package.json b/package.json index 05cb298fc..7019f8c3b 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "dev:stdio": "pnpm --filter '@sentry/mcp-server...' --if-present run build && dotenv -e .env -e .env.local -- pnpm --parallel --filter @sentry/mcp-server --filter @sentry/mcp-core --filter @sentry/mcp-server-mocks --if-present run dev", "build": "dotenv -e .env -e .env.local -- pnpm -r --filter '!sentry' --filter '!sentry-cli-docs' --if-present run build", "build:cli": "dotenv -e .env -e .env.local -- pnpm --filter sentry run build && pnpm --filter sentry-cli-docs run build", - "check:generated": "pnpm --filter @sentry/mcp-core generate-definitions && pnpm --filter @sentry/toolkit-core generate && git diff --exit-code -- packages/mcp-core/src/toolDefinitions.json packages/mcp-core/src/skillDefinitions.json plugins/sentry-mcp/agents/sentry-mcp.md plugins/sentry-mcp-experimental/agents/sentry-mcp.md packages/toolkit-core/src/generated", + "check:generated": "pnpm --filter @sentry/mcp-core generate-definitions && pnpm run generate:parsers && git diff --exit-code -- packages/mcp-core/src/toolDefinitions.json packages/mcp-core/src/skillDefinitions.json plugins/sentry-mcp/agents/sentry-mcp.md plugins/sentry-mcp-experimental/agents/sentry-mcp.md packages/toolkit-core/src/generated", + "generate:parsers": "node scripts/generate-parsers.mjs packages/toolkit-core", "deploy:docs": "pnpm --filter sentry run generate:schema && pnpm --filter sentry run generate:docs && pnpm --filter sentry-cli-docs run build && pnpm --filter sentry-cli-docs run deploy", "eval": "pnpm --filter '@sentry/mcp-server-evals...' --if-present run build && dotenv -e .env -e .env.local -- pnpm --filter @sentry/mcp-server-evals run eval", "eval:ci": "pnpm --filter '@sentry/mcp-server-evals...' --if-present run build && CI=true dotenv -e .env -e .env.local -- pnpm --stream -r run eval:ci", @@ -58,6 +59,7 @@ "oxfmt": "catalog:", "oxlint": "catalog:", "oxlint-tsgolint": "catalog:", + "peggy": "^5.1.0", "simple-git-hooks": "catalog:", "tsdown": "catalog:", "tsx": "catalog:", diff --git a/packages/cli/CONTRIBUTING.md b/packages/cli/CONTRIBUTING.md index 524dca951..5951b2020 100644 --- a/packages/cli/CONTRIBUTING.md +++ b/packages/cli/CONTRIBUTING.md @@ -16,16 +16,20 @@ sentry project list [org] [--limit N] [--json] sentry issue list [/] [--json] ``` -**Target syntax** (generated from `src/lib/target-syntax.ts`): +**Target syntax** (generated from `grammar/org-project-target.pegjs`): | Target | Meaning | |--------|---------| | _(omitted)_ | Auto-detect the org and project from config, DSNs, or the directory name | +| `https://.sentry.io/...` | A Sentry web URL. Selects the org and project in the URL, or the whole organization when it names no project | | `/` | That project in that organization | | `/,` | Several projects in the same organization (commands that accept several projects) | +| `/` | A project in that organization whose display name matches. A target with a space is a name, not a slug | | `/` | The whole organization | | `` | A project named `` in any accessible organization. If no project matches, commands that accept an organization use the organization named ``. When both exist, the project wins: add the trailing slash (`/`) to select the organization | +| `/` | Same as `` | +| `` | A project whose display name matches, in any accessible organization | **Rationale**: Positional arguments follow `gh` CLI conventions and are more concise than flags. diff --git a/packages/cli/grammar/org-project-target.d.ts b/packages/cli/grammar/org-project-target.d.ts new file mode 100644 index 000000000..6782a8659 --- /dev/null +++ b/packages/cli/grammar/org-project-target.d.ts @@ -0,0 +1,33 @@ +// Types of the parser generated from org-project-target.pegjs. The generator +// copies this file to src/generated/org-project-target-grammar.d.ts and +// rewrites relative imports to resolve from there. + +import type { ParsedOrgProject } from "../src/lib/arg-parsing.js"; + +/** + * Hooks the target grammar calls for character-level validation, so its + * errors match the rest of the CLI. See `parseOrgProjectArg()`. + */ +export type OrgProjectTargetGrammarOptions = { + /** Accept `/,` lists. */ + multi?: boolean; + /** Throw when `value` is not a valid slug. */ + checkSlug: (value: string, label: string) => void; + /** Throw when `value` starts with `@`. */ + rejectAt: (value: string, label: string) => void; + /** Throw a validation error. */ + invalid: (message: string, field?: string) => never; + startRule?: "target"; +}; + +export declare class SyntaxError extends Error { + expected: unknown; + found: string | null; + location: unknown; +} + +/** Parse a non-empty target that is not a URL. */ +export declare function parse( + input: string, + options: OrgProjectTargetGrammarOptions, +): Exclude; diff --git a/packages/cli/grammar/org-project-target.pegjs b/packages/cli/grammar/org-project-target.pegjs new file mode 100644 index 000000000..6d37e6f47 --- /dev/null +++ b/packages/cli/grammar/org-project-target.pegjs @@ -0,0 +1,117 @@ +// Grammar for the `[/][[,...]]` target positional. +// `pnpm run generate:grammar` compiles it, with the types in +// org-project-target.d.ts, into the gitignored src/generated/; the build, +// typecheck, and test scripts run it. +// +// `parseOrgProjectArg()` (src/lib/arg-parsing.ts) runs this parser after it +// keeps the first non-blank line and handles the omitted and URL forms. The +// grammar owns the structure; character-level checks go through the +// `checkSlug`, `rejectAt`, and `invalid` hooks in `options`, so its errors +// match the rest of the CLI. +// +// The target syntax tables in the docs are generated from the `@form` lines +// here (script/generate-docs-sections.ts). Every `target` alternative needs +// at least one `@form` line, or `@invalid` when it only rejects input. A +// `@form` line reads `@form — `, both in Markdown; indented +// `//` lines continue the meaning. The two forms below never reach the +// grammar, so they are listed here: +// +// @form _(omitted)_ — Auto-detect the org and project from config, DSNs, or +// the directory name +// @form `https://.sentry.io/...` — A Sentry web URL. Selects the org and +// project in the URL, or the whole organization when it names no project + +{ + const { multi = false, checkSlug, rejectAt, invalid } = options; + + function slug(value, label) { + rejectAt(value, label); + checkSlug(value, label); + return value; + } + + function orgSlug(value) { + return slug(value, "organization slug"); + } + + function projectSlug(value) { + return slug(value, "project slug"); + } + + function displayName(value, org) { + rejectAt(value, "project slug"); + return { + type: "project-search", + projectSlug: value, + originalSlug: value, + ...(org !== undefined && { org }), + }; + } + + // Slugs cannot contain commas, so `org/web,api` is unambiguous. Tokens are + // trimmed, empty ones dropped, and duplicates removed in first-seen order. + function projectList(org, raw) { + const tokens = [...new Set(raw.split(",").map((t) => t.trim()))].filter( + (t) => t !== "", + ); + if (tokens.length === 0) { + invalid("Invalid project slug: comma-separated list is empty.", "project"); + } + if (tokens.some((t) => t.includes(" "))) { + invalid( + "Comma-separated project targets must be slugs, not display names.", + "project", + ); + } + const [first, second, ...others] = tokens.map(projectSlug); + return { + type: "explicit", + org, + project: first, + ...(second !== undefined && { projects: [first, second, ...others] }), + }; + } +} + +// Peggy runs a rule's action as soon as that rule matches, even when the +// enclosing alternative fails later and the parser backtracks. The token +// rules below therefore only capture text, and validation happens in the +// action of the `target` alternative that wins. Otherwise `@latest` would be +// rejected as an organization slug before it is parsed as a project (covered +// by "@latest throws with redirect to issue view" in arg-parsing.test.ts). +target + // @form `/` — That project in that organization + = o:org "/" p:slug !{ return multi && p.includes(","); } + { const org = orgSlug(o); return { type: "explicit", org, project: projectSlug(p) }; } + // @form `/,` — Several projects in the same + // organization (commands that accept several projects) + / o:org "/" &{ return multi; } l:list { return projectList(orgSlug(o), l); } + // @form `/` — A project in that organization whose + // display name matches. A target with a space is a name, not a slug + / o:org "/" p:name { const org = orgSlug(o); return displayName(p, org); } + // @form `/` — The whole organization + / o:org "/" !. { return { type: "org-all", org: orgSlug(o) }; } + // @form `` — A project named `` in any accessible organization. + // If no project matches, commands that accept an organization use the + // organization named ``. When both exist, the project wins: add the + // trailing slash (`/`) to select the organization + // @form `/` — Same as `` + / "/"? p:slug { return { type: "project-search", projectSlug: projectSlug(p) }; } + // @form `` — A project whose display name matches, in any + // accessible organization + / "/"? p:name { return displayName(p); } + // @invalid + / "/" !. { invalid('Invalid format: "/" requires a project slug (e.g., "/cli")'); } + +// Everything before the first slash. +org = $[^/]+ + +// The rest of the input when it has no spaces. Slashes, commas, and other +// characters are kept so `checkSlug` reports them. +slug = $([^ ]+ !.) + +// The rest of the input when it has a space: a display name. +name = $([^ ]* " " .*) + +// The rest of the input when it has a comma: a project list. +list = $([^,]* "," .*) diff --git a/packages/cli/package.json b/packages/cli/package.json index a63b8a3b3..23a39bc24 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -72,7 +72,8 @@ "generate:sdk": "pnpm tsx script/generate-sdk.ts", "generate:skill": "pnpm tsx script/generate-skill.ts", "generate:banner": "pnpm tsx script/generate-banner-sixel.ts", - "generate:docs": "node script/ensure-generated.mjs schema && pnpm run generate:banner && pnpm run generate:completions && pnpm run generate:command-docs && pnpm run generate:docs-sections && pnpm run generate:skill", + "generate:docs": "node script/ensure-generated.mjs schema && pnpm run generate:grammar && pnpm run generate:banner && pnpm run generate:completions && pnpm run generate:command-docs && pnpm run generate:docs-sections && pnpm run generate:skill", + "generate:grammar": "node ../../scripts/generate-parsers.mjs .", "generate:completions": "pnpm tsx script/generate-completion-targets.ts", "generate:docs-sections": "pnpm tsx script/generate-docs-sections.ts", "generate:schema": "pnpm tsx script/generate-api-schema.ts", @@ -136,6 +137,7 @@ "marked": "^15.0.12", "node-sqlite3-wasm": "0.8.59", "p-limit": "^7.3.0", + "peggy": "^5.1.0", "picomatch": "^4.0.4", "pixelmatch": "^7.2.0", "pngjs": "^7.0.0", diff --git a/packages/cli/script/ensure-generated.mjs b/packages/cli/script/ensure-generated.mjs index 92370db0e..2a7cd780f 100644 --- a/packages/cli/script/ensure-generated.mjs +++ b/packages/cli/script/ensure-generated.mjs @@ -2,10 +2,11 @@ /** * Generate the gitignored `src/generated/` files when they are missing. * - * A fresh checkout has no API schema, skill content, or completion targets, so - * `pnpm run cli`, `typecheck`, and the tests used to fail until contributors - * ran `generate:schema` and `generate:docs` by hand. Scripts that need those - * files call this first, so there is no bootstrap step to document or forget. + * A fresh checkout has no API schema, target parser, skill content, or + * completion targets, so `pnpm run cli`, `typecheck`, and the tests used to + * fail until contributors ran `generate:schema` and `generate:docs` by hand. + * Scripts that need those files call this first, so there is no bootstrap + * step to document or forget. * * Usage: * node script/ensure-generated.mjs # everything the CLI imports @@ -18,6 +19,11 @@ import { execSync } from "node:child_process"; import { existsSync } from "node:fs"; const SCHEMA = "src/generated/api-schema.json"; +/** Produced by `generate:grammar`; imported by `src/lib/arg-parsing.ts`. */ +const GRAMMAR_OUTPUTS = [ + "src/generated/org-project-target-grammar.js", + "src/generated/org-project-target-grammar.d.ts", +]; /** Produced by `generate:docs`; imported at runtime by `src/`. */ const DOCS_OUTPUTS = [ "src/generated/skill-content.ts", @@ -32,6 +38,9 @@ function run(script) { if (!existsSync(SCHEMA)) { run("generate:schema"); } +if (process.argv[2] !== "schema" && !GRAMMAR_OUTPUTS.every(existsSync)) { + run("generate:grammar"); +} if (process.argv[2] !== "schema" && !DOCS_OUTPUTS.every(existsSync)) { run("generate:docs"); } diff --git a/packages/cli/script/generate-docs-sections.ts b/packages/cli/script/generate-docs-sections.ts index edfa991fa..099d77fc3 100644 --- a/packages/cli/script/generate-docs-sections.ts +++ b/packages/cli/script/generate-docs-sections.ts @@ -13,7 +13,8 @@ * - getting-started.mdx: platform support table * - agent-guidance.md: destructive commands (from `destructive` commands) * - agentic-usage.md: detected agents (from AGENT_DISPLAY_NAMES) - * - commands/index.md fragment, CONTRIBUTING.md: target syntax (TARGET_FORMS) + * - commands/index.md fragment, CONTRIBUTING.md: target syntax (from the + * `@form` lines in grammar/org-project-target.pegjs) * - plugins/README.md: skill file layout (paths checked on disk) * * Unlike generate-command-docs.ts (which produces gitignored files from scratch), @@ -26,6 +27,7 @@ import { existsSync, mkdirSync } from "node:fs"; import { access, readFile, writeFile } from "node:fs/promises"; +import peggy from "peggy"; import { DOCS_CONTENT, DOCS_FRAGMENTS } from "./paths.js"; // Bootstrap: skill-content stub (same pattern as generate-command-docs.ts) @@ -42,7 +44,6 @@ if (!skillContentExists) { } import type { EnvVarEntry } from "../src/lib/env-registry.js"; -import type { TargetForm } from "../src/lib/target-syntax.js"; import type { RouteInfo, RouteMap } from "../src/lib/introspect.js"; const { routes } = await import("../src/app.js"); @@ -50,9 +51,9 @@ const { extractAllRoutes } = await import("../src/lib/introspect.js"); const { OAUTH_SCOPES } = await import("../src/lib/oauth.js"); const { ENV_VAR_REGISTRY } = await import("../src/lib/env-registry.js"); const { AGENT_DISPLAY_NAMES } = await import("../src/lib/detect-agent.js"); -const { TARGET_FORMS } = await import("../src/lib/target-syntax.js"); -const { parseOrgProjectArg } = await import("../src/lib/arg-parsing.js"); const pkg = JSON.parse(await readFile("package.json", "utf-8")); +const TARGET_GRAMMAR = "grammar/org-project-target.pegjs"; +const targetGrammar = await readFile(TARGET_GRAMMAR, "utf-8"); const workspace = JSON.parse(await readFile("../../package.json", "utf-8")); const isCheck = process.argv.includes("--check"); @@ -605,25 +606,88 @@ function generateDetectedAgents(): string { // Section: Target Syntax (commands/index.md fragment, CONTRIBUTING.md) // --------------------------------------------------------------------------- +/** `// @form — ` or `// @invalid`. */ +const TARGET_ANNOTATION_RE = /^\s*\/\/ @(form|invalid)\b ?(.*)$/; +/** An indented `//` line continuing the annotation above it. */ +const TARGET_CONTINUATION_RE = /^\s*\/\/ {3}(\S.*)$/; +const COMMENT_LINE_RE = /^\s*\/\//; + +/** One annotation; `start` and `end` are 1-based line numbers. */ +type TargetAnnotation = { + start: number; + end: number; + kind: string; + text: string; +}; + +/** Collect the `@form` and `@invalid` annotations of a grammar. */ +function readTargetAnnotations(lines: string[]): TargetAnnotation[] { + const annotations: TargetAnnotation[] = []; + for (const [index, line] of lines.entries()) { + const annotation = TARGET_ANNOTATION_RE.exec(line); + const continuation = TARGET_CONTINUATION_RE.exec(line); + const previous = annotations.at(-1); + if (annotation) { + annotations.push({ + start: index + 1, + end: index + 1, + kind: annotation[1] ?? "", + text: annotation[2] ?? "", + }); + } else if (continuation && previous?.end === index) { + previous.text += ` ${continuation[1]}`; + previous.end = index + 1; + } + } + return annotations; +} + /** - * Generate the target syntax table from TARGET_FORMS. Each example goes - * through the real parser, so generation fails when the docs and the parser - * disagree about what a form means. + * Generate the target syntax table from the `@form` lines in the target + * grammar. Generation fails when an alternative of the `target` rule has no + * `@form` or `@invalid` line in the comments right above it, so a new form + * cannot ship undocumented. */ function generateTargetSyntax(): string { - const lines = ["| Target | Meaning |", "|--------|---------|"]; - for (const [type, forms] of Object.entries(TARGET_FORMS)) { - for (const form of forms as readonly TargetForm[]) { - const parsed = parseOrgProjectArg(form.example, { multi: form.multi }); - if (parsed.type !== type) { - throw new Error( - `TARGET_FORMS: ${form.syntax} example ${JSON.stringify(form.example)} parses as "${parsed.type}", not "${type}"`, - ); - } - lines.push(`| ${form.syntax} | ${form.meaning} |`); + // Checkouts with CRLF line endings (Windows) must parse the same. + const lines = targetGrammar.split(/\r?\n/); + const annotations = readTargetAnnotations(lines); + + const target = peggy.parser + .parse(targetGrammar, { grammarSource: TARGET_GRAMMAR }) + .rules.find((rule) => rule.name === "target"); + if (target?.expression.type !== "choice") { + throw new Error( + `${TARGET_GRAMMAR}: expected a "target" rule with alternatives`, + ); + } + for (const alternative of target.expression.alternatives) { + const line = alternative.location.start.line; + let first = line; + while (first > 1 && COMMENT_LINE_RE.test(lines[first - 2] ?? "")) { + first -= 1; + } + if (!annotations.some((a) => a.start >= first && a.start < line)) { + throw new Error( + `${TARGET_GRAMMAR}:${line}: target alternative has no @form or @invalid line`, + ); } } - return lines.join("\n"); + + const rows = ["| Target | Meaning |", "|--------|---------|"]; + for (const { start, kind, text } of annotations) { + if (kind !== "form") { + continue; + } + const [syntax, meaning] = text.split(" — "); + if (!(syntax && meaning)) { + throw new Error( + `${TARGET_GRAMMAR}:${start}: expected "@form — "`, + ); + } + rows.push(`| ${syntax} | ${meaning} |`); + } + return rows.join("\n"); } // --------------------------------------------------------------------------- diff --git a/packages/cli/src/lib/arg-parsing.ts b/packages/cli/src/lib/arg-parsing.ts index 0fa732f39..0e6d50792 100644 --- a/packages/cli/src/lib/arg-parsing.ts +++ b/packages/cli/src/lib/arg-parsing.ts @@ -6,6 +6,7 @@ * project list) and single-item commands (issue view, explain, plan). */ +import { parse as parseTarget } from "../generated/org-project-target-grammar.js"; import type { LogSortDirection } from "./api/logs.js"; import { ContextError, ValidationError, validationError } from "./errors.js"; import { validateResourceId } from "./input-validation.js"; @@ -26,9 +27,9 @@ import { isAllDigits } from "./utils.js"; * is effectively a no-op — valid slug characters pass through unchanged. * It remains as a stable call-site for future normalization rules. * - * Spaces are handled separately: {@link looksLikeDisplayName} detects them - * and the parsing layer routes to a display-name search path that skips - * slug validation and API lookup entirely. + * Spaces are handled separately: the target grammar + * (`grammar/org-project-target.pegjs`) routes them to a display-name search + * that skips slug validation. * * @param slug - Raw slug string from CLI input * @returns The slug unchanged with `normalized: false` @@ -40,38 +41,6 @@ export function normalizeSlug(slug: string): { return { slug, normalized: false }; } -/** - * Check if a string looks like a display name rather than a slug. - * Display names contain spaces, which are never valid in slugs. - */ -function looksLikeDisplayName(input: string): boolean { - return input.includes(" "); -} - -/** - * Split a project selector on commas. - * - * Sentry slugs cannot contain commas, so `org/web,api,worker` is unambiguous. - * Trims whitespace, drops empty tokens, and de-duplicates while preserving - * first-seen order. - * - * @param rawProject - Raw project selector, potentially containing commas - * @returns A non-empty list of unique project slugs in input order - * @throws {ValidationError} When every token is empty (`org/,,,`) - */ -function splitProjectSelector(rawProject: string): [string, ...string[]] { - const [first, ...rest] = [ - ...new Set(rawProject.split(",").map((part) => part.trim())), - ].filter((slug) => slug !== ""); - if (first === undefined) { - throw new ValidationError( - "Invalid project slug: comma-separated list is empty.", - "project", - ); - } - return [first, ...rest]; -} - /** * Project slugs from an explicit `org/project` parse. * @@ -759,147 +728,12 @@ function rejectAtSelector(value: string, label: string): void { ); } -/** - * Parse a slash-delimited `org/project` string into a {@link ParsedOrgProject}. - * Applies {@link normalizeSlug} to both components and validates against - * URL injection characters. - */ -function parseSlashOrgProject(input: string, multi: boolean): ParsedOrgProject { - const slashIndex = input.indexOf("/"); - const rawOrg = input.slice(0, slashIndex); - const rawProject = input.slice(slashIndex + 1); - - if (!rawOrg) { - // "/cli" → search for project across all orgs - if (!rawProject) { - throw new ValidationError( - 'Invalid format: "/" requires a project slug (e.g., "/cli")', - ); - } - rejectAtSelector(rawProject, "project slug"); - if (looksLikeDisplayName(rawProject)) { - // Spaces → display name, not a slug. Skip slug validation and let - // the resolution layer do a fuzzy name-based search. - return { - type: "project-search", - projectSlug: rawProject, - originalSlug: rawProject, - }; - } - const np = normalizeSlug(rawProject); - validateResourceId(np.slug, "project slug"); - return { - type: "project-search", - projectSlug: np.slug, - }; - } - - rejectAtSelector(rawOrg, "organization slug"); - const no = normalizeSlug(rawOrg); - validateResourceId(no.slug, "organization slug"); - - if (!rawProject) { - // "sentry/" → list all projects in org - return { - type: "org-all", - org: no.slug, - ...(no.normalized && { normalized: true }), - }; - } - - // "sentry/web,api,worker" for list commands that explicitly opt in - if (multi && rawProject.includes(",")) { - return parseExplicitProjectList(no, rawProject); - } - - rejectAtSelector(rawProject, "project slug"); - if (looksLikeDisplayName(rawProject)) { - // Spaces → display name, not a slug. Skip slug validation and let the - // resolution layer do a fuzzy name-based search (mirrors the bare-slug - // and leading-slash paths). Prevents a hard ValidationError when callers - // pass a project display name in "org/project" form (CLI-1RA). - return { - type: "project-search", - projectSlug: rawProject, - originalSlug: rawProject, - org: no.slug, - }; - } - const np = validateProjectSlugToken(rawProject); - const normalized = no.normalized || np.normalized; - return { - type: "explicit", - org: no.slug, - project: np.slug, - ...(normalized && { normalized: true }), - }; -} - -/** - * Validate one project slug token: reject `@` selectors, normalize, and - * check for URL-injection characters. Shared by the single-slug and - * comma-list explicit paths. - */ -function validateProjectSlugToken(token: string): { - slug: string; - normalized: boolean; -} { - rejectAtSelector(token, "project slug"); - const np = normalizeSlug(token); - validateResourceId(np.slug, "project slug"); - return np; -} - -/** - * Parse `org/web,api,worker` into an explicit target with `projects` set. - * - * Display names are rejected here: a comma list is a slug selector, not a - * search. Each token is validated independently so a bad slug fails at parse - * time instead of as a 404 against the concatenated string. - */ -function parseExplicitProjectList( - org: { slug: string; normalized: boolean }, - rawProject: string, -): ParsedOrgProject { - const [firstToken, ...restTokens] = splitProjectSelector(rawProject); - if ([firstToken, ...restTokens].some(looksLikeDisplayName)) { - throw new ValidationError( - "Comma-separated project targets must be slugs, not display names.", - "project", - ); - } - - const first = validateProjectSlugToken(firstToken); - const rest = restTokens.map(validateProjectSlugToken); - const normalized = - org.normalized || - first.normalized || - rest.some((token) => token.normalized); - const [second, ...others] = rest; - - return { - type: "explicit", - org: org.slug, - project: first.slug, - ...(second !== undefined && { - projects: [first.slug, second.slug, ...others.map((t) => t.slug)], - }), - ...(normalized && { normalized: true }), - }; -} - /** * Parse an org/project positional argument string. * - * Supports the following patterns: - * - `undefined` or empty → auto-detect from DSN/config - * - `https://sentry.io/organizations/org/...` → extract from Sentry URL - * - `sentry/cli` → explicit org and project - * - `sentry/web,api` → explicit org and multiple projects when - * `options.multi` is true - * - `sentry/` → org with all projects - * - `/cli` → search for project across all orgs (leading slash) - * - `cli` → search for project across all orgs + * Empty input auto-detects and Sentry web URLs are read here; every other + * form is defined by `grammar/org-project-target.pegjs`, which also + * documents them. * * @param arg - Input string from CLI positional argument * @param options.multi - Parse comma-separated project slugs after @@ -934,31 +768,14 @@ export function parseOrgProjectArg( return orgProjectFromUrl(urlParsed); } - let parsed: ParsedOrgProject; - if (trimmed.includes("/")) { - parsed = parseSlashOrgProject(trimmed, options.multi === true); - } else { - // No slash → search for project across all orgs - rejectAtSelector(trimmed, "project slug"); - if (looksLikeDisplayName(trimmed)) { - // Spaces → display name, not a slug. Skip slug validation and let - // the resolution layer do a fuzzy name-based search. - parsed = { - type: "project-search", - projectSlug: trimmed, - originalSlug: trimmed, - }; - } else { - const np = normalizeSlug(trimmed); - validateResourceId(np.slug, "project slug"); - parsed = { - type: "project-search", - projectSlug: np.slug, - }; - } - } - - return parsed; + return parseTarget(trimmed, { + multi: options.multi === true, + checkSlug: validateResourceId, + rejectAt: rejectAtSelector, + invalid: (message, field) => { + throw new ValidationError(message, field); + }, + }); } /** diff --git a/packages/cli/src/lib/target-syntax.ts b/packages/cli/src/lib/target-syntax.ts deleted file mode 100644 index 810f8ded1..000000000 --- a/packages/cli/src/lib/target-syntax.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Documented forms of the `/` target positional, keyed by the - * `parseOrgProjectArg()` result type each form produces. - * - * The target syntax tables in the commands overview and CONTRIBUTING.md are - * generated from this map (`script/generate-docs-sections.ts`). The key set is - * checked by TypeScript, so a new parse result type cannot ship undocumented, - * and the generator parses every example and fails when the parser returns a - * different type, so the table cannot disagree with the parser. - */ - -import type { ProjectSpecificationType } from "./arg-parsing.js"; - -/** A `parseOrgProjectArg()` result type. */ -type SpecificationType = - (typeof ProjectSpecificationType)[keyof typeof ProjectSpecificationType]; - -/** One documented target form. */ -export type TargetForm = { - /** Syntax shown in the docs table (Markdown). */ - readonly syntax: string; - /** Example value; `undefined` means the argument is omitted. */ - readonly example: string | undefined; - /** Whether the example needs `parseOrgProjectArg(..., { multi: true })`. */ - readonly multi?: boolean; - /** What the form selects (Markdown). */ - readonly meaning: string; -}; - -/** Target forms by parse result type, in documentation order. */ -export const TARGET_FORMS = { - "auto-detect": [ - { - syntax: "_(omitted)_", - example: undefined, - meaning: - "Auto-detect the org and project from config, DSNs, or the directory name", - }, - ], - explicit: [ - { - syntax: "`/`", - example: "my-org/my-project", - meaning: "That project in that organization", - }, - { - syntax: "`/,`", - example: "my-org/web,api", - multi: true, - meaning: - "Several projects in the same organization (commands that accept several projects)", - }, - ], - "org-all": [ - { - syntax: "`/`", - example: "my-org/", - meaning: "The whole organization", - }, - ], - "project-search": [ - { - syntax: "``", - example: "my-project", - meaning: - "A project named `` in any accessible organization. If no project matches, commands that accept an organization use the organization named ``. When both exist, the project wins: add the trailing slash (`/`) to select the organization", - }, - ], -} as const satisfies Record; diff --git a/packages/toolkit-core/README.md b/packages/toolkit-core/README.md index 5ab41b480..fdb1d9ab9 100644 --- a/packages/toolkit-core/README.md +++ b/packages/toolkit-core/README.md @@ -31,8 +31,9 @@ marks tokens invalid or fills `parsed` values. A product that needs those subclasses `TokenConverter` and passes it as `converter`. The parser is compiled ahead of time into `src/generated/` and committed, -since this package has no build step. After editing a grammar, run -`pnpm --filter @sentry/toolkit-core generate`; the pre-commit hook does this -for staged grammar changes, and `pnpm run check:generated` fails in CI when +since this package has no build step. Its types live in +`grammar/search-syntax.d.ts`. After editing either file, run +`pnpm run generate:parsers`; the pre-commit hook does this for staged +grammar changes, and `pnpm run check:generated` fails in CI when the committed parser is out of date. When updating the grammar from upstream, copy the fixtures too. diff --git a/packages/toolkit-core/grammar/search-syntax.d.ts b/packages/toolkit-core/grammar/search-syntax.d.ts new file mode 100644 index 000000000..51dd8ec90 --- /dev/null +++ b/packages/toolkit-core/grammar/search-syntax.d.ts @@ -0,0 +1,25 @@ +/** + * Options the search grammar reads from `options`. The grammar builds every + * token through `TokenConverter`, so callers decide the token shapes and the + * key-type predicates. See `src/search-syntax.ts` for the default converter. + */ +export type SearchGrammarOptions = { + /** Constructed with `{ text, location, config }` once per parse. */ + TokenConverter: new (opts: never) => unknown; + TermOperator: { DEFAULT: string }; + FilterType: Record; + config: unknown; + startRule?: "search"; +}; + +export declare class SyntaxError extends Error { + expected: unknown; + found: string | null; + location: unknown; +} + +/** Parse a search query. Throws {@link SyntaxError} when it does not match. */ +export declare function parse( + input: string, + options: SearchGrammarOptions, +): unknown[]; diff --git a/packages/toolkit-core/package.json b/packages/toolkit-core/package.json index 0c0a64fe1..8e726ca52 100644 --- a/packages/toolkit-core/package.json +++ b/packages/toolkit-core/package.json @@ -61,13 +61,11 @@ } }, "scripts": { - "generate": "tsx script/generate-parsers.ts", "test": "vitest run", "test:ci": "vitest run --reporter=default --reporter=junit --outputFile=tests.junit.xml", "tsc": "tsc --noEmit" }, "devDependencies": { - "peggy": "^5.1.0", "typescript": "catalog:", "vitest": "catalog:" } diff --git a/packages/toolkit-core/script/generate-parsers.ts b/packages/toolkit-core/script/generate-parsers.ts deleted file mode 100644 index b789918f1..000000000 --- a/packages/toolkit-core/script/generate-parsers.ts +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env tsx -/** - * Compile the shared PEG grammars into committed ES module parsers. - * - * toolkit-core ships TypeScript source with no build step, so the CLI, the - * stdio MCP server, and the Cloudflare worker all import the generated - * parsers straight from `src/generated/`. `pnpm run check:generated` in the - * workspace root regenerates them and fails when the committed output is out - * of date. - * - * `grammar/search-syntax.pegjs` is a byte-for-byte copy of the Sentry - * frontend grammar at - * https://github.com/getsentry/sentry/blob/master/static/app/components/searchSyntax/grammar.pegjs - * so the two can converge on one source. - * - * Usage: - * pnpm --filter @sentry/toolkit-core generate - */ - -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import peggy from "peggy"; - -const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const HEADER = - "// Auto-generated by packages/toolkit-core/script/generate-parsers.ts — DO NOT EDIT\n"; - -type GrammarSpec = { - grammar: string; - output: string; - allowedStartRules: string[]; - declarations: string; -}; - -const GRAMMARS: GrammarSpec[] = [ - { - grammar: "grammar/search-syntax.pegjs", - output: "src/generated/search-syntax-grammar", - allowedStartRules: ["search"], - declarations: `${HEADER} -/** - * Options the search grammar reads from \`options\`. The grammar builds every - * token through \`TokenConverter\`, so callers decide the token shapes and the - * key-type predicates. See \`../search-syntax.ts\` for the default converter. - */ -export type SearchGrammarOptions = { - /** Constructed with \`{ text, location, config }\` once per parse. */ - TokenConverter: new (opts: never) => unknown; - TermOperator: { DEFAULT: string }; - FilterType: Record; - config: unknown; - startRule?: "search"; -}; - -export declare class SyntaxError extends Error { - expected: unknown; - found: string | null; - location: unknown; -} - -/** Parse a search query. Throws {@link SyntaxError} when it does not match. */ -export declare function parse( - input: string, - options: SearchGrammarOptions, -): unknown[]; -`, - }, -]; - -for (const spec of GRAMMARS) { - const grammarSource = await readFile( - resolve(PACKAGE_ROOT, spec.grammar), - "utf-8", - ); - const parserSource = peggy.generate(grammarSource, { - output: "source", - format: "es", - allowedStartRules: spec.allowedStartRules, - grammarSource: spec.grammar, - }); - - const outputBase = resolve(PACKAGE_ROOT, spec.output); - await mkdir(dirname(outputBase), { recursive: true }); - await writeFile(`${outputBase}.js`, `${HEADER}\n${parserSource}\n`); - await writeFile(`${outputBase}.d.ts`, spec.declarations); - console.log(`Generated ${spec.output}.js and ${spec.output}.d.ts`); -} diff --git a/packages/toolkit-core/src/generated/search-syntax-grammar.d.ts b/packages/toolkit-core/src/generated/search-syntax-grammar.d.ts index 9e69d214c..bbc26e0a6 100644 --- a/packages/toolkit-core/src/generated/search-syntax-grammar.d.ts +++ b/packages/toolkit-core/src/generated/search-syntax-grammar.d.ts @@ -1,9 +1,9 @@ -// Auto-generated by packages/toolkit-core/script/generate-parsers.ts — DO NOT EDIT +// Auto-generated by scripts/generate-parsers.mjs — DO NOT EDIT /** * Options the search grammar reads from `options`. The grammar builds every * token through `TokenConverter`, so callers decide the token shapes and the - * key-type predicates. See `../search-syntax.ts` for the default converter. + * key-type predicates. See `src/search-syntax.ts` for the default converter. */ export type SearchGrammarOptions = { /** Constructed with `{ text, location, config }` once per parse. */ diff --git a/packages/toolkit-core/src/generated/search-syntax-grammar.js b/packages/toolkit-core/src/generated/search-syntax-grammar.js index 7c418005a..ae63c86f5 100644 --- a/packages/toolkit-core/src/generated/search-syntax-grammar.js +++ b/packages/toolkit-core/src/generated/search-syntax-grammar.js @@ -1,4 +1,4 @@ -// Auto-generated by packages/toolkit-core/script/generate-parsers.ts — DO NOT EDIT +// Auto-generated by scripts/generate-parsers.mjs — DO NOT EDIT // @generated by Peggy 5.1.0. // diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 885adef6a..915b104dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -229,6 +229,9 @@ importers: oxlint-tsgolint: specifier: 'catalog:' version: 7.0.2004 + peggy: + specifier: ^5.1.0 + version: 5.1.0 simple-git-hooks: specifier: 'catalog:' version: 2.13.1 @@ -554,6 +557,9 @@ importers: p-limit: specifier: ^7.3.0 version: 7.3.2 + peggy: + specifier: ^5.1.0 + version: 5.1.0 picomatch: specifier: ^4.0.4 version: 4.0.5 @@ -987,9 +993,6 @@ importers: packages/toolkit-core: devDependencies: - peggy: - specifier: ^5.1.0 - version: 5.1.0 typescript: specifier: 'catalog:' version: 5.8.3 diff --git a/scripts/generate-parsers.mjs b/scripts/generate-parsers.mjs new file mode 100644 index 000000000..c0e085c84 --- /dev/null +++ b/scripts/generate-parsers.mjs @@ -0,0 +1,117 @@ +#!/usr/bin/env node +/** + * Compile a package's PEG grammars into ES module parsers. + * + * Every `grammar/.pegjs` in the package becomes + * `src/generated/-grammar.js`. Its types live in the hand-written + * `grammar/.d.ts` next to the grammar, a real TypeScript file that + * 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. + * + * toolkit-core has no build step, so its parser is committed and + * `pnpm run check:generated` fails when it is out of date. The CLI generates + * its parser at build time (`pnpm run generate:grammar` in packages/cli). + * + * `packages/toolkit-core/grammar/search-syntax.pegjs` is a byte-for-byte copy + * of the Sentry frontend grammar at + * https://github.com/getsentry/sentry/blob/master/static/app/components/searchSyntax/grammar.pegjs + * so the two can converge on one source. + * + * Usage: + * node scripts/generate-parsers.mjs + */ + +import { existsSync } from "node:fs"; +import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; +import { join, relative, resolve, sep } from "node:path"; +import peggy from "peggy"; +import ts from "typescript"; + +const HEADER = + "// Auto-generated by scripts/generate-parsers.mjs — DO NOT EDIT\n"; + +const packageDir = process.argv[2]; +if (!packageDir) { + console.error("Usage: node scripts/generate-parsers.mjs "); + process.exit(1); +} +const grammarDir = resolve(packageDir, "grammar"); +const outputDir = resolve(packageDir, "src", "generated"); + +/** Point a relative specifier written from `grammarDir` at `outputDir`. */ +function rebaseSpecifier(specifier) { + if (!specifier.startsWith(".")) { + return specifier; + } + const path = relative(outputDir, resolve(grammarDir, specifier)) + .split(sep) + .join("/"); + return path.startsWith(".") ? path : `./${path}`; +} + +/** Module specifier literals of import/export declarations and `import()` types. */ +function moduleSpecifiers(file) { + const found = []; + const visit = (node) => { + const specifier = + ts.isImportDeclaration(node) || ts.isExportDeclaration(node) + ? node.moduleSpecifier + : ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument) + ? node.argument.literal + : undefined; + if (specifier && ts.isStringLiteral(specifier)) { + found.push(specifier); + } + ts.forEachChild(node, visit); + }; + visit(file); + return found; +} + +/** Rebase every relative module specifier, located with the TypeScript parser. */ +function rebaseDeclarations(source, fileName) { + const file = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest); + let result = source; + // Splice from the end so earlier offsets stay valid. Each literal spans + // its quotes; only the text between them is replaced. + for (const literal of moduleSpecifiers(file).toReversed()) { + const start = literal.getStart(file) + 1; + const end = literal.end - 1; + result = + result.slice(0, start) + + rebaseSpecifier(literal.text) + + result.slice(end); + } + return result; +} + +const grammars = (await readdir(grammarDir)).filter((file) => + file.endsWith(".pegjs"), +); +await mkdir(outputDir, { recursive: true }); + +for (const file of grammars) { + const name = file.slice(0, -".pegjs".length); + const grammarPath = join(grammarDir, file); + const declarationsPath = join(grammarDir, `${name}.d.ts`); + if (!existsSync(declarationsPath)) { + throw new Error(`${declarationsPath} is missing`); + } + + const parserSource = peggy.generate(await readFile(grammarPath, "utf-8"), { + output: "source", + format: "es", + grammarSource: relative(packageDir, grammarPath).split(sep).join("/"), + }); + const declarations = rebaseDeclarations( + await readFile(declarationsPath, "utf-8"), + declarationsPath, + ); + + const outputBase = join(outputDir, `${name}-grammar`); + await writeFile(`${outputBase}.js`, `${HEADER}\n${parserSource}\n`); + await writeFile(`${outputBase}.d.ts`, `${HEADER}\n${declarations}`); + console.log(`Generated ${relative(process.cwd(), outputBase)}.{js,d.ts}`); +} diff --git a/scripts/pre-commit-generated.mjs b/scripts/pre-commit-generated.mjs index 49ffccf3c..98aed210b 100644 --- a/scripts/pre-commit-generated.mjs +++ b/scripts/pre-commit-generated.mjs @@ -28,10 +28,10 @@ const GENERATED_TASKS = [ }, { name: "toolkit-core grammar parsers", - command: ["pnpm", "--filter", "@sentry/toolkit-core", "generate"], + command: ["pnpm", "run", "generate:parsers"], inputs: [ - /^packages\/toolkit-core\/grammar\/.*\.pegjs$/, - /^packages\/toolkit-core\/script\/generate-parsers\.ts$/, + /^packages\/toolkit-core\/grammar\//, + /^scripts\/generate-parsers\.mjs$/, ], outputs: [ "packages/toolkit-core/src/generated/search-syntax-grammar.js",