Skip to content

ref(cli): Define the org/project target syntax as a PEG grammar #1637

Closed

Description

@sentry-junior

Follow-up to #1626 (review thread). Today the <org>/<project> target syntax exists in two places: the hand-written parser (parseOrgProjectArg / parseSlashOrgProject in packages/cli/src/lib/arg-parsing.ts) and TARGET_FORMS (src/lib/target-syntax.ts), which generates the docs table. #1626 ties the two together by checking each example, but the list of forms itself can still drift. It already has: the parser accepts forms the generated table never mentions:

  • /<project>: project search across orgs (leading slash)
  • <org>/<Project Name> and bare display names with spaces: name search
  • Sentry web URLs

A PEG grammar would be the single source for both the parser and the docs.

Feasibility: yes, for the target syntax

I wrote a spike: a 44-line peggy grammar that replaces parseSlashOrgProject plus the bare-name branch of parseOrgProjectArg. I compared it with the current parser on 82,866 cases: the 56 inputs used in the tests, every string up to 4 characters over a b / , space @ - _ ? # \n 1 % ., and some extra edge cases, each with multi on and off. It gives 0 differences, including identical ValidationError messages.

  • There is a precedent. packages/toolkit-core already compiles grammar/search-syntax.pegjs with peggy 5 through script/generate-parsers.ts, and commits the output under src/generated/. The CLI already bundles that parser. Peggy is a devDependency, so check:deps (no runtime dependencies) is unaffected.
  • Cost is small. The generated parser is about 19 KB unminified. A parse takes about 1 µs.
  • What stays in TypeScript:
    • The URL branch (parseSentryUrl plus the applySentryUrlContext side effect).
    • firstNonBlankLine preprocessing.
    • Character validation (validateResourceId, rejectAtSelector), which the grammar calls through options hooks so error messages don't change.
  • Pitfall the spike found. Peggy runs a rule's action as soon as that rule matches, even if the surrounding alternative later fails and the parser backtracks. A version that validated the org inside the org rule reported "not an organization slug" for a bare @latest. Validation has to happen in the winning alternative's action (the spike does this). That needs a comment in the grammar and a test.
  • Docs. The syntax column can come from the grammar: annotate each target alternative (for example // @form <org>/ — The whole organization) and have generate-docs-sections.ts read the grammar source. TARGET_FORMS and its example check then go away, along with the missing-forms problem above.

Proposed scope

  1. Add packages/cli/grammar/org-project-target.pegjs. Generate it the way toolkit-core does: either move the generator into a shared script, or reuse it with a per-package spec list. Commit the output under src/generated/, and fail CI when the generated parser is stale.
  2. Replace parseSlashOrgProject and the bare-name branch with the generated parser. Keep parseOrgProjectArg's signature and the URL path unchanged. Keep test/lib/arg-parsing.test.ts as is, and add the differential corpus as a one-off migration check.
  3. Generate the target syntax table from the grammar annotations. Remove TARGET_FORMS, and document the forms that are missing today.
  4. Later, and optional: parseIssueArg (about 450 lines: short IDs, org/project-SUFFIX, #, :, selectors) is the larger candidate. Leave it out of the first pass.

Clean up: normalizeSlug is now a no-op (it always returns normalized: false). It and the normalized / originalSlug plumbing look removable.

Related: #1564 (target positionals own parsing and resolution). The grammar would sit behind parseOrgProjectArg, so the lazy target positionals from #1594 need no changes.

Spike grammar (validated against the current parser)
// `[<org>/][<project>[,<project>...]]` target grammar (spike).
// Structure lives here; character-level validation is delegated to hooks so
// error messages stay identical to the hand-written parser.
{
  const { multi = false, checkSlug, rejectAt, invalid } = options;
  const spaced = (s) => s.includes(" ");
  function slug(value, label) { rejectAt(value, label); checkSlug(value, label); return value; }
  function project(value, org) {
    rejectAt(value, "project slug");
    if (spaced(value)) {
      return { type: "project-search", projectSlug: value, originalSlug: value, ...(org !== undefined && { org }) };
    }
    checkSlug(value, "project slug");
    return org === undefined
      ? { type: "project-search", projectSlug: value }
      : { type: "explicit", org, project: value };
  }
}

target
  = "/" p:rest                            { return project(p); }
  / "/"                                   { invalid('Invalid format: "/" requires a project slug (e.g., "/cli")'); }
  / o:org "/" &{ return multi; } l:list   { return l(slug(o, "organization slug")); }
  / o:org "/" p:rest                      { return project(p, slug(o, "organization slug")); }
  / o:org "/"                             { return { type: "org-all", org: slug(o, "organization slug") }; }
  / p:rest                                { return project(p); }

// Raw text only: actions run even when an enclosing alternative later fails,
// so validation must wait for the alternative that wins.
org = $[^/]+

// Comma list: only when it has a comma; returns a builder so the org is
// validated first, like the hand-written parser.
list = raw:$([^,]* "," .*) {
  return (o) => {
    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(spaced)) invalid("Comma-separated project targets must be slugs, not display names.", "project");
    const [first, second, ...others] = tokens.map((t) => slug(t, "project slug"));
    return { type: "explicit", org: o, project: first, ...(second !== undefined && { projects: [first, second, ...others] }) };
  };
}

rest = $.+

via Junior system actor event.

--

View Junior Session in Sentry

Activity

  1. linear-code commented on Oct 23, 2026

    @linear-code
  2. self-assigned this
    on Oct 23, 2026
  3. added a commit that references this issue on Oct 23, 2026
    9aefecd
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions