From 3e56bca3f4a3a11dfb44c1f86efd12411cd71afd Mon Sep 17 00:00:00 2001 From: Kin Lane Date: Mon, 17 Aug 2026 15:46:37 -0400 Subject: [PATCH 1/2] Research: the alias layer as it actually is, plus a post arguing #20 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aliases are the format's answer to the JSONPath criticism, and they are barely usable — not because the idea is wrong, but because of where they are scoped. Adds two things, both read from @stoplight/spectral-core 1.23.1 as vendored at v6.16.2 rather than from memory: - research/aliases.md — the evidence, citing files for every claim, and an explicit list of four things the read could not settle. - A post for 2026-09-11 — the argument, pointing at spotlight-spec#20. The finding that matters: aliases do not cross `extends`, deliberately. The test is named `should refuse to resolve externally defined aliases`. An alias is therefore private to the file declaring it, which is why the JSONPath criticism survives a feature built to answer it — and the refusal also buys real collision-freedom, so #20 inherits that problem rather than dodging it. None of this is documented outside a test name. Also: six aliases ship, all in `oas`; AsyncAPI and Arazzo define none, against a standing docs promise otherwise. Co-Authored-By: Claude Opus 5 (1M context) --- ...he-format-is-trapped-inside-one-ruleset.md | 181 ++++++++++++ research/aliases.md | 264 ++++++++++++++++++ research/index.md | 18 ++ 3 files changed, 463 insertions(+) create mode 100644 _posts/2026-09-11-the-best-idea-in-the-format-is-trapped-inside-one-ruleset.md create mode 100644 research/aliases.md diff --git a/_posts/2026-09-11-the-best-idea-in-the-format-is-trapped-inside-one-ruleset.md b/_posts/2026-09-11-the-best-idea-in-the-format-is-trapped-inside-one-ruleset.md new file mode 100644 index 0000000..8e8eab2 --- /dev/null +++ b/_posts/2026-09-11-the-best-idea-in-the-format-is-trapped-inside-one-ruleset.md @@ -0,0 +1,181 @@ +--- +title: The best idea in the format is trapped inside one ruleset +description: >- + Aliases already solve the JSONPath problem, and almost nobody can use them. Six of them ship, in + one ruleset, and they cannot cross a ruleset boundary. Here is what aliases actually do today. +--- + +The most common criticism of this linter has nothing to do with the linter. It is that writing rules +means writing JSONPath, and a lot of people would rather not. + +That criticism is fair, and it is also out of date — the format has had an answer since v6.2, and +almost nobody uses it. **Aliases** let you name a JSONPath expression once and reference it by name +everywhere else. The idea is right. The way it is scoped is what keeps it from mattering. + +This post is the background reading for +[spotlight-spec#20]({{ site.repo_rules }}/issues/20), which proposes lifting aliases out of the core +ruleset and into their own namespace. Everything below is what exists **today**, read out of the +v6.16.2 source, so the proposal gets argued against the real thing rather than against a memory of +it. The line-by-line version, with file citations for every claim, is on the research page: +[the alias layer, as it actually is](/research/aliases/). + +## What an alias is + +Two forms. The first is a name pointing at a list of path expressions: + +```yaml +aliases: + PathItem: + - $.paths[*] +``` + +Rules then reference it from `given` with a leading `#`: + +```yaml +rules: + path-must-have-summary: + given: "#PathItem" +``` + +The reference is a **prefix**, not a substitution of the whole value — whatever follows the alias +name is appended to each expression the alias resolves to. So `#PathItem.parameters` becomes +`$.paths[*].parameters`, and `#Info.contact.name` becomes `$.info.contact.name`. Alias names must +match `^[A-Za-z][A-Za-z0-9_-]*$`. + +Aliases compose. An alias can be defined in terms of another alias, to arbitrary depth. Cycles are +detected and reported with the full resolution stack, which is a nicer failure than most of this +format manages. + +The second form is where it gets genuinely interesting — a **scoped** alias, which resolves to +different expressions depending on the format of the document being linted: + +```yaml +aliases: + ResponseObject: + targets: + - formats: [oas2] + given: ["#OperationObject.responses[*]", "$.responses[*]"] + - formats: [oas3] + given: ["#OperationObject.responses[*]", "$.components.responses[*]"] +``` + +That is one rule governing two structurally different documents, with the structural difference +handled once, in a named thing, instead of being smeared across every rule that has to care. **This +is the feature that should be famous, and is not.** + +## What actually ships + +Six aliases, all of them in the OpenAPI ruleset: + +| Alias | Form | Resolves to | +|---|---|---| +| `PathItem` | simple | `$.paths[*]` | +| `OperationObject` | simple | `#PathItem[get,put,post,delete,options,head,patch,trace]` | +| `SecurityRequirementObject` | simple | `$.security[*]`, `#OperationObject.security[*]` | +| `ResponseObject` | scoped | oas2 and oas3 variants | +| `LinkObject` | scoped | oas3 only | +| `ArrayProperties` | scoped | oas2/3.0 one way, 3.1 another — because 3.1 allows `type` to be an array | + +Three levels of nesting in production: `LinkObject` is defined in terms of `ResponseObject`, which is +defined in terms of `OperationObject`, which is defined in terms of `PathItem`. The feature works, +and the bundled ruleset leans on it. + +**The AsyncAPI ruleset defines none. The Arazzo ruleset defines none.** The documentation has +promised otherwise since the feature shipped: + +> This will be followed by the Spectral core rulesets providing a common set of aliases for OpenAPI +> and AsyncAPI so that users don't have to do the work at all. + +Half-delivered, and then the project stopped moving. + +## The part that changes the argument + +Here is the behaviour almost nobody knows about, because it is documented in exactly one place — the +name of a unit test. + +**Aliases do not cross `extends`.** + +If ruleset B defines `PathItem`, and ruleset A extends B, then rules written in A **cannot use +`#PathItem`**. They get `Alias "PathItem" does not exist`. The test that pins this down is called +`should refuse to resolve externally defined aliases`, and "refuse" is the right word — this is +deliberate, not an oversight. + +The mechanism is that every rule resolves aliases against the ruleset it was *written in*, not the +ruleset it ends up running in. Rules inherited through `extends` keep their original owner and keep +resolving against that owner's aliases. Which has one genuinely good consequence — two extended +rulesets can define the same alias name differently and never collide — and one severe one: + +**An alias is private to the file it was declared in.** It cannot be published, shared, or reused. +Every organization extending the OpenAPI ruleset and writing house rules on top has to redeclare +`PathItem` and `OperationObject` themselves, or go back to raw JSONPath. Which is what they do, which +is why the JSONPath criticism survives a feature that was supposed to answer it. + +Nothing in `docs/` says this. You find out by getting the error. + +## Two more sharp edges worth knowing + +**A scoped alias that matches no format resolves to nothing, silently.** No error, no warning — the +rule simply never runs. If your document is not detected as a format that one of the alias targets +names, everything downstream of that alias quietly does not happen. A rule that never fires and a +rule that passes look identical in the output. (See also the specification's note that format +identifiers are open: a typo like `oas31` validates fine and matches no document. Same failure mode, +different door.) + +**When targets overlap, the last one wins.** Targets are scanned in reverse and the first match +returns — deliberately, to match the "last value wins" convention used by overrides. Exactly one +target's expressions are used; there is no union across matching targets. A document detected as both +oas2 and oas3 gets the oas3 target because it is written later in the list. + +And a smaller one with real performance consequences: **a single scoped alias changes how the entire +ruleset is evaluated.** If a ruleset contains only simple aliases, every `given` is expanded once, at +load time. Add one scoped alias anywhere and the whole ruleset switches to resolving every `given` at +lint time, on every run, because resolution now depends on the document. Since the bundled OpenAPI +ruleset contains three scoped aliases, that is the mode everyone linting OpenAPI is already in. + +## What this argues for + +Not "add more aliases." The problems are structural, and all four of them are the same problem seen +from different sides: + +- Aliases are **declared on a ruleset**, so they die at the ruleset boundary. +- The useful ones are **OpenAPI-shaped**, but they live in a ruleset you may not want to extend + rather than attached to the OpenAPI *format*, which is the thing they actually describe. +- Formats can only be selected between **inside** an alias, so format-awareness is a property of + something private rather than something shared. +- And because none of it is shareable, the JSONPath the alias layer was built to hide is still what + most people write. + +[spotlight-spec#20]({{ site.repo_rules }}/issues/20) proposes the shape: elevate aliases out of the +core ruleset into their own addressable namespace, attach the OpenAPI aliases to the OpenAPI format +so anything linting OpenAPI gets them without importing a ruleset it does not otherwise want, and +switch the examples and documentation so the alias route is the documented way to write a rule and +JSONPath is what you drop to when you need it. + +Do that and the criticism stops being true — not because JSONPath went away, but because most people +would stop having to touch it. It also makes rule portability to other policy engines +([#13]({{ site.repo_rules }}/issues/13)) substantially more tractable, since the fewer rules carrying +hand-written JSONPath, the more of them can be translated at all. + +## Open questions, genuinely open + +The issue is a proposal, not a decision, and there are things nobody has answered: + +- Does the alias namespace version with the format, with the specification, or on its own? +- Can a ruleset override an alias it inherits from a format — and is that a footgun? +- Is the alias layer normative, or a registry the specification points at? +- Is the current `extends` refusal worth preserving in some form? It prevents a real class of + collision, and any sharing mechanism has to answer what it replaces that protection with. + +That last one is the one most likely to be underestimated. The current behaviour is not a bug; it is +a trade-off made in favour of isolation over reuse. The argument for #20 is that the trade was made +in the wrong direction for a format that outgrew one tool — but it was a trade, and whatever replaces +it inherits the problem it was solving. + +Arguments welcome on [the issue]({{ site.repo_rules }}/issues/20). + +--- + +*Read from `@stoplight/spectral-core` 1.23.1 as vendored in +[spotlight-tools]({{ site.repo_cli }}) at v6.16.2. The alias implementation carries no fork-specific +changes — this is upstream behaviour, described rather than altered. The `aliases` property is +specified in the [ruleset specification]({{ site.spec_site }}).* diff --git a/research/aliases.md b/research/aliases.md new file mode 100644 index 0000000..70819b0 --- /dev/null +++ b/research/aliases.md @@ -0,0 +1,264 @@ +--- +layout: default +nav: research +title: The alias layer, as it actually is +description: >- + A reproducible read of how aliases work in the ruleset format today — both forms, what ships + built-in, the extends boundary, and three behaviours documented only by a unit test. +permalink: /research/aliases/ +--- + +# The alias layer, as it actually is + +Aliases are the format's answer to the criticism it gets most often — that writing rules means +writing JSONPath. The answer has existed since v6.2 and is barely used, and the reasons for that are +structural rather than cosmetic. + +This page is the evidence behind [spotlight-spec#20]({{ site.repo_rules }}/issues/20), which proposes +lifting aliases out of the core ruleset into their own namespace. It describes **what exists today**, +not what is proposed. Every claim below cites a file, so it can be checked and so it can be shown +wrong. + +> **Method.** Read from `@stoplight/spectral-core` **1.23.1** as vendored in +> [spotlight-tools]({{ site.repo_cli }}) at **v6.16.2**, against the schema published in +> [spotlight-spec]({{ site.repo_rules }}). Paths are relative to the tooling repository unless +> stated. The alias implementation carries **no fork-specific commits** — the most recent changes to +> it are upstream, `feat(core): allow extending rulesets with aliases` (January 2026) and +> `feat(core): further adjustments for extending rulesets with aliases` (April 2026). What follows is +> upstream behaviour, described rather than altered. + +--- + +## The two forms + +`aliases` is an object at the root of a ruleset. Names must match `^[A-Za-z][A-Za-z0-9_-]*$` +(`packages/core/src/ruleset/meta/ruleset.schema.json`). Values take one of two forms, discriminated +at validation time by whether the value is an object. + +### Simple + +An **array** of path expressions — always an array, never a bare string: + +```yaml +aliases: + PathItem: + - $.paths[*] +``` + +The array requirement is not incidental; it was narrowed deliberately, recorded in the core +changelog as *"aliases take only array-ish given"*. + +### Scoped + +An object with a required, non-empty `targets` array, each target requiring **both** `formats` and +`given`: + +```yaml +aliases: + ResponseObject: + targets: + - formats: [oas2] + given: ["#OperationObject.responses[*]", "$.responses[*]"] + - formats: [oas3] + given: ["#OperationObject.responses[*]", "$.components.responses[*]"] +``` + +This is the only mechanism in the format by which one name resolves differently depending on the +document being linted. + +The TypeScript mirror is `packages/core/src/ruleset/types.ts`: + +```ts +export type RulesetAliasesDefinition = Record; +``` + +Both forms are specified in the [ruleset specification]({{ site.spec_site }}) — `$defs.aliases`, +`$defs.alias`, `$defs.scopedAlias`, `$defs.aliasTarget` in +`schema/v1/spectral-ruleset.schema.json`. + +--- + +## Referencing + +A rule references an alias from `given` with a leading `#`. The matcher is +`/^#([A-Za-z0-9_-]+)/` (`packages/core/src/ruleset/alias.ts`). + +**The reference is a prefix, not a whole-value substitution.** Everything after the alias name is +appended verbatim to each expression the alias resolves to. So with `Info: ['$.info']`, the +reference `#Info.contact` produces `$.info.contact`. + +Aliases compose, recursively, with **no depth limit**. Cycles are detected via a resolution stack and +reported with the full chain — `Alias "Test" is circular. Resolution stack: Test -> Contact -> Info +-> Root -> Info`. Each branch of the recursion gets its own copy of the stack, so a diamond (the same +alias reached down two different paths) is legal; only a true cycle throws. + +Three failure messages exist, all in `alias.ts`: + +| Condition | Message | +|---|---| +| `#` not followed by a valid name | `Alias must match /^#([A-Za-z0-9_-]+)/` | +| Cycle | `Alias "X" is circular. Resolution stack: …` | +| Unknown name | `Alias "X" does not exist` | + +--- + +## What ships built-in + +**Six aliases, all in the OpenAPI ruleset** (`packages/rulesets/src/oas/index.ts`). The AsyncAPI and +Arazzo rulesets define **none**. + +| Alias | Form | Resolves to | +|---|---|---| +| `PathItem` | simple | `$.paths[*]` | +| `OperationObject` | simple | `#PathItem[get,put,post,delete,options,head,patch,trace]` | +| `SecurityRequirementObject` | simple | `$.security[*]`, `#OperationObject.security[*]` | +| `ResponseObject` | scoped | oas2 → `$.responses[*]`; oas3 → `$.components.responses[*]` (both plus `#OperationObject.responses[*]`) | +| `LinkObject` | scoped | oas3 only — `$.components.links[*]`, `#ResponseObject.links[*]` | +| `ArrayProperties` | scoped | oas2/3.0 one expression; 3.1 adds a second, because 3.1 permits `type` to be an array | + +Three levels of nesting ship in production: `LinkObject` → `ResponseObject` → `OperationObject` → +`PathItem`. The feature is not theoretical; the bundled ruleset depends on it. + +The documentation has promised more since the feature landed +(`docs/guides/4c-aliases.md`): + +> This will be followed by the Spectral core rulesets providing a common set of aliases for OpenAPI +> and AsyncAPI so that users don't have to do the work at all. + +Delivered for OpenAPI. Not delivered for AsyncAPI. + +*(Two literal defects in that same doc's "Common Aliases" table, noted for whoever fixes it: it +references `#Path_Item`, an alias the table never defines, and one cell carries a stray double +quote.)* + +--- + +## The extends boundary + +**Aliases do not cross `extends`.** If ruleset B defines `PathItem` and ruleset A extends B, rules +written in A cannot use `#PathItem` — they get `Alias "PathItem" does not exist`. + +This is deliberate. The test asserting it is named +**`should refuse to resolve externally defined aliases`** +(`packages/core/src/ruleset/__tests__/ruleset.test.ts`). + +The mechanism: a rule resolves aliases against `this.owner.aliases`, where `owner` is the ruleset in +whose `rules` the rule was **written** (`packages/core/src/ruleset/rule.ts`). Rules pulled in via +`extends` are taken by reference and keep their original owner, and the rule merger explicitly +preserves it (`packages/core/src/ruleset/mergers/rules.ts`). The validator agrees — for a path under +`rules` it returns `ruleset.aliases` alone, merging only for `overrides` +(`packages/core/src/ruleset/validation/validators/alias.ts`). + +Consequences, both real: + +- **Good.** Two extended rulesets may define the same alias name with different values and never + collide. There is a fixture for exactly this (`extended-alias-collision.ts`) asserting no errors. +- **Severe.** An alias is private to the file that declares it. It cannot be published, shared, or + reused. Every organization extending the OpenAPI ruleset and adding house rules must redeclare + `PathItem` and `OperationObject` itself — or go back to raw JSONPath. + +**Nothing in `docs/` states this.** The behaviour is documented by a test name. + +Aliases may be declared at the root of a ruleset or inside a **file-scoped override**, and must be +declared explicitly in each — the docs say this is "to avoid ambiguity". An override targeting a +**JSON Pointer** cannot declare aliases at all; that branch of the schema permits only `files` and +`rules`. + +`aliases` is **not** a rule-level property. Both the implementation meta-schema and the published +specification list the permitted rule keys with `additionalProperties: false`, and `aliases` is not +among them. + +--- + +## Three behaviours worth knowing + +### 1. A scoped alias that matches no format resolves to nothing, silently + +`resolveAliasForFormats` returns `null` when the document's format set is empty or matches no target, +and the caller skips it — no error, no warning. The rule simply never runs. Test: +`should drop aliases not matching any target`. + +A rule that never fires and a rule that passes are indistinguishable in the output. The +specification's conformance notes flag the same failure mode arriving through a different door: +format identifiers are open, so a typo like `oas31` validates cleanly and matches no document. + +### 2. When targets overlap, the last one wins + +Targets are scanned **last to first**, and the first target with any format in the document's +detected set returns — **its entire `given` array, and only its**. There is no union across matching +targets. The source comment states the intent: *"we start from the end to be consistent with +overrides etc. - we generally tend to pick the 'last' value."* + +Demonstrated by a document matching both oas2 and oas3 resolving to the oas3 target, because it is +written later. + +### 3. One scoped alias changes how the whole ruleset is evaluated + +`Ruleset` computes a single boolean, `hasComplexAliases`, at construction. It is `true` if **any** +alias in the ruleset uses the scoped form. + +- **False** — every `given` is expanded **once, at load time**, with no format context. +- **True** — no `given` is expanded at load time. All of them resolve **at lint time**, on every run, + because resolution now depends on the document. + +The flag is per-ruleset, not per-alias: one scoped alias moves every rule in that ruleset to lint-time +resolution. Since the bundled OpenAPI ruleset contains three scoped aliases, that is the mode +everyone linting OpenAPI is already in. + +--- + +## Where formats and aliases meet + +Aliases are tied to formats **only** through the scoped form. A simple alias has no format awareness +at all and is expanded with no format context. + +The format set used at lint time is the **document's detected formats**, not the rule's — the runner +passes `this.document.formats`, populated by evaluating every format predicate registered on the +ruleset against the resolved document. Matching is by object identity on the predicate function. + +At **validation** time the picture differs: the validator derives formats from the **rule's own +`formats` array**. The asymmetry is worth being aware of when reasoning about what gets caught before +a run and what does not. + +--- + +## Open, and stated as open + +Four things this read could not settle. They are listed because a research page that only publishes +what it is sure of is not a research page. + +1. **Whether silent resolution-to-nothing is intended design or tolerated behaviour.** The code does + it and a test asserts it, but no comment or document justifies it. +2. **Whether formats named only inside an alias target are meant to go unregistered.** `Ruleset` + collects formats from three places — its own `formats`, those of extended rulesets, and rules' + own `formats` — and alias targets are not among them, so a format appearing *only* in a target + could never be detected and that target could never match. Moot in the bundled ruleset, which + declares all four formats at the top. Code-read; no test either way. +3. **Whether a scoped alias's nested references go unchecked when the referencing rule declares no + `formats`.** The validation path suggests they do. No test confirms or denies it. +4. **Whether anyone intends to change this.** No fork commit touches the alias implementation, and + there were no design documents or issues about aliases in either repository before + [#20]({{ site.repo_rules }}/issues/20). + +--- + +## Why this is the argument for #20 + +Four problems, one shape. Aliases are declared on a **ruleset**, so they die at the ruleset boundary. +The useful ones are OpenAPI-shaped but live in a ruleset rather than attached to the OpenAPI +**format**, which is the thing they actually describe. Format-awareness is only expressible *inside* +an alias, so it is a property of something private. And because none of it is shareable, the JSONPath +the layer exists to hide is still what most people write. + +[spotlight-spec#20]({{ site.repo_rules }}/issues/20) proposes elevating aliases into their own +addressable namespace and attaching the OpenAPI aliases to the OpenAPI format. It is a proposal, not +a decision — and the `extends` refusal it would replace is a real trade-off in favour of isolation, +not a bug, so whatever replaces it inherits the collision problem that refusal was solving. + +
+
+

Read this differently, or found an error? The source is right there — say so.

+ Argue on the issue → + Or in the discussion → +
+
diff --git a/research/index.md b/research/index.md index 94febe1..b4a0aa3 100644 --- a/research/index.md +++ b/research/index.md @@ -70,6 +70,20 @@ open. +
+
+
+
The alias layer, as it actually is
+

+ A reproducible read of how aliases work today — both forms, the six that ship built-in, + the extends boundary that makes them unshareable, and three behaviours + documented only by a unit test. +

+ Read → +
+
+
+ --- @@ -86,6 +100,10 @@ keyword searches), GitHub repository search, and the upstream project's own comm rulesets in the wild — which was itself last updated eighteen months ago and undercounts significantly. +**Format behaviour** was read directly from the source — `@stoplight/spectral-core` 1.23.1 as +vendored at v6.16.2, and the published ruleset schema. Those pages cite files rather than +summarising, so every claim can be checked against the tree and shown wrong if it is. + **Everything else** — the parts that came from conversations with people who run this in production — informs the reasoning but is not reproduced here. Where a claim rests only on a private conversation, it is either omitted or stated generically enough that no one is identified. From 84db292d713f4a49831b38685d1b56f177a886b9 Mon Sep 17 00:00:00 2001 From: Kin Lane Date: Mon, 17 Aug 2026 15:48:34 -0400 Subject: [PATCH 2/2] Publish the alias post today rather than queueing it Re-dates the post from 2026-09-11 to 2026-08-17 so it goes live on merge instead of waiting three weeks. The three later posts stay queued. Co-Authored-By: Claude Opus 5 (1M context) --- ...-the-best-idea-in-the-format-is-trapped-inside-one-ruleset.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename _posts/{2026-09-11-the-best-idea-in-the-format-is-trapped-inside-one-ruleset.md => 2026-08-17-the-best-idea-in-the-format-is-trapped-inside-one-ruleset.md} (100%) diff --git a/_posts/2026-09-11-the-best-idea-in-the-format-is-trapped-inside-one-ruleset.md b/_posts/2026-08-17-the-best-idea-in-the-format-is-trapped-inside-one-ruleset.md similarity index 100% rename from _posts/2026-09-11-the-best-idea-in-the-format-is-trapped-inside-one-ruleset.md rename to _posts/2026-08-17-the-best-idea-in-the-format-is-trapped-inside-one-ruleset.md