Repository navigation
ref(cli): Define the org/project target syntax as a PEG grammar #1637
Description
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-corealready compilesgrammar/search-syntax.pegjswith peggy 5 throughscript/generate-parsers.ts, and commits the output undersrc/generated/. The CLI already bundles that parser. Peggy is a devDependency, socheck: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 (
parseSentryUrlplus theapplySentryUrlContextside effect). firstNonBlankLinepreprocessing.- Character validation (
validateResourceId,rejectAtSelector), which the grammar calls throughoptionshooks so error messages don't change.
- The URL branch (
- 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
orgrule 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
targetalternative (for example// @form <org>/ — The whole organization) and havegenerate-docs-sections.tsread the grammar source.TARGET_FORMSand its example check then go away, along with the missing-forms problem above.
Proposed scope
- 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 undersrc/generated/, and fail CI when the generated parser is stale. - Replace
parseSlashOrgProjectand the bare-name branch with the generated parser. KeepparseOrgProjectArg's signature and the URL path unchanged. Keeptest/lib/arg-parsing.test.tsas is, and add the differential corpus as a one-off migration check. - Generate the target syntax table from the grammar annotations. Remove
TARGET_FORMS, and document the forms that are missing today. - 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.
--
Activity
linear-code commentedon Oct 23, 2026
MCP-1127
ref(cli): Define the org/project target syntax as a PEG grammar (#1638)