Repository navigation
fix(compilers/openapi): warn on a duplicate server name - #726
Conversation
A document may declare the same server name at two entries. Both entries lower, but an emitter rendering one client per server, or a consumer keying by the name, cannot tell the two hosts apart. The code is an error, matching conflicting-operation-id at the neighbouring uniqueness rule: the document writes one name twice, which is not the shape duplicate-status-key warns about, where two spellings collapse under this compiler's own neutralization. It reports only declared names compared to one another; a hint derived from a URL template is not a claim the document made. Only the document's servers list is read: a path-item or operation servers list is preserved raw under Unmodeled and builds no ir.Server.
lowerServers now walks the servers list once more, in source order, and reports every entry whose declared name a lower-indexed entry already claimed: one diagnostic per repeat at /servers/<i>/name naming the first claimant, and nothing else changes — both servers still lower with the name the document wrote. A nil entry or an empty name claims nothing, and a hint derived from a URL template is never compared, so the collisions the compiler's own canonicalization makes stay tolerated; serverName's comment now scopes that tolerance to hints and says why a declared name is not one.
The reported table covers the issue's repro, three entries sharing one name (one report per repeat, in source order), and one declaration mounted twice by a YAML alias; a hand-built nil entry keeps the pointers on the source indices. The not-reported table covers distinct names, two unnamed servers whose URL hints collide (asserting the hints really do collide, so the row cannot pass vacuously), a declared name equal to another entry's hint, and two empty names.
The base document declares one server, so only the patched tree has a repeat to report; the diagnostic about the appended entry names the overlay, which is the source the rule has to be reading.
Resolve the conflicts with main and bring the doc comments this branch adds under the 100-word cap main now enforces. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GCzyGPq9DQ7RLfagZm2JB5
| claimed[name] = ptr | ||
| continue | ||
| } | ||
| diags = append(diags, c.DiagAt(ir.SeverityError, diag.DuplicateServerName, ptr, |
There was a problem hiding this comment.
One declaration mounted twice (a YAML alias such as - *s, or a merge key <<: *base that carries name) is reported here as an error, and the alias row in meta_test.go pins that. The document wrote the name once, so "the document's own claim is contradictory" (the diag.go rationale) does not hold, and "the name identifies two hosts" is false when both mounts have the same URL.
#502 settled this class for operationIds: a declaration mounted more than once is a warning (DuplicateOperationID), and only a second declaration that writes the id again is an error (ConflictingOperationID). operationIDClaims / byDeclaration already separate the two by shared node. This loop treats every repeat as the second kind, so a spec that exits 0 on main now exits 1 with no --fail-on setting that avoids it (the CLI only accepts error or warning).
Probed on this branch: x-base: &b {name: prod} with two servers using <<: *b reports /servers/1/name, exactly as two separately written entries do. Suggest grouping by declaring node as byDeclaration does, with mounts of one declaration a warning and separate declarations an error.
There was a problem hiding this comment.
Agreed, fixed in b23b370. Every repeat is now a warning, which is the #502 precedent for one declaration mounted more than once. The message no longer claims the name identifies two hosts: it now reads "a consumer keying servers by name cannot tell them apart", which holds for a mount as much as for two declarations. A merge-key row (<<: *b carrying name) joins the alias row, and both expect the warning.
| if s == nil { | ||
| continue | ||
| } | ||
| name := s.GetName() |
There was a problem hiding this comment.
name is only defined on the Server Object from 3.2, but this runs for every version. Probed on this branch: a 3.0.3 or 3.1.0 document with two servers carrying name: prod exits 1 with openapi/duplicate-server-name, while a single name on the same document is silent (no unknown-key report, since the parser reads the field). The doc comment on DuplicateServerName in diag.go cites OAS 3.2.1 §4.5.1, which that document is not written against.
Because --fail-on only offers error and warning, such a document has no way to compile with exit 0. The PR description lists the sub-3.2 read as an observation only, but this change is what turns it into a refusal. Either gate the rule on a 3.2 document or downgrade it when the version does not define the field, and add a row for a 3.1 document either way so the choice is pinned.
There was a problem hiding this comment.
Kept on every version, deliberately: the parser reads name on 3.0 and 3.1, and serverName lowers it, so the IR carries the colliding names whatever the version. As a warning it no longer refuses such a document under the default --fail-on. A 3.1 row in the reported table pins the choice, and the serverName comment now says the field is defined by 3.2 but read on every version (b23b370, trimmed in 63384ae). The citation in diag.go is gone with the rewrite.
| continue | ||
| } | ||
| ptr := ids.Ptr("servers", strconv.Itoa(i), "name") | ||
| first, seen := claimed[name] |
There was a problem hiding this comment.
The rule keys on the raw string, but the identity emitters consume is Naming.Canonical. Probed: prod / Prod / prod-eu / prod_eu / https api example com v 1 / https_api_example_com_v_1 pairs each lower to one canonical name and produce no diagnostic. The stated reason for the error (an emitter naming one client per server cannot stay unambiguous) applies to those pairs just as much, so the rule catches only the spelling-identical case.
That may be the right scope (the spec's uniqueness is case-sensitive), but then the comment on serverName ("Two declared names colliding are the document's error") overstates it. It should say exact-string collisions, and the canonical-collision case should be stated as tolerated or reported.
There was a problem hiding this comment.
Kept exact strings, since the spec's uniqueness is case-sensitive, and now pinned (b23b370): the not-reported table has prod/Prod and prod-eu/prod_eu rows, each asserting that the canonical names really do collide, so keying on serverName(s).Canonical reddens both. The serverName comment now says names that differ only once canonicalized are tolerated like URL hints, and only exact repeats are reported.
| // document has a repeat to report, and the diagnostic about the appended entry | ||
| // names the overlay as the source it came from. The base's own server keeps the | ||
| // document as its source. | ||
| func TestCompile_OverlayIntroducingADuplicateServerNameCreditsTheOverlay(t *testing.T) { |
There was a problem hiding this comment.
The diagnostic's source is whichever source owns the repeat's position, and the message names the first claimant by pointer alone. This test only covers the overlay appending the repeat. If the overlay instead rewrites an earlier entry to collide with a later base entry (target: $.servers[0], update: {name: prod} over a base of dev then prod), the finding is credited to the base document at /servers/1/name and says it duplicates /servers/0/name, which the base wrote as dev. Probed on this branch: Provenance.Source == 0.
The collision was introduced by the overlay, so a reader opening spec.yaml cannot find the duplicate. Worth either crediting the source of the first claimant when it differs, or naming it in the message, and adding this ordering as a second overlay case.
There was a problem hiding this comment.
Fixed in b23b370. When only the earlier claim came from the overlay, the overlay made the two collide, so the report sits at that entry with Provenance.Source == 1 and names the base's entry in the message. The test is now a three-case table: the overlay appends a repeat, the overlay renames an entry ahead of the base's claimant (your $.servers[0] ordering, reported at /servers/0/name), and the overlay writes both entries (reported at the later one, as in a single document). Dropping the swap reddens the second case, and swapping without checking the repeat's source reddens the third.
| claimed := make(map[string]jsontext.Pointer, len(servers)) | ||
| var diags []ir.Diagnostic | ||
| for i, s := range servers { | ||
| if s == nil { |
There was a problem hiding this comment.
This nil guard is dead: (*soa.Server).GetName returns "" on a nil receiver, so a nil entry already falls through to the name == "" continue. Mutation check on this branch: deleting these three lines leaves DuplicateServerName|LowerServers green, so TestDuplicateServerName_NilEntryKeepsSourceIndices does not pin the guard. The only thing it does pin is that i is the range index. The guard and the comment that mentions nil entries can go, with the test kept for the index claim and its doc reworded to say that.
There was a problem hiding this comment.
Removed in b23b370. The check now runs inside the lowerServers loop, after the nil continue that lowerServer already needs. TestDuplicateServerName_NilEntryKeepsSourceIndices stays for the source-index claim, and its doc now claims only that.
| diags = append(diags, serverDiags...) | ||
| out = append(out, one) | ||
| } | ||
| diags = append(diags, duplicateServerNameDiags(c, servers)...) |
There was a problem hiding this comment.
This is a second walk over the same list that rebuilds each server's pointer from scratch (ids.Ptr("servers", strconv.Itoa(i), "name")) when the loop above already builds ids.Ptr("servers", strconv.Itoa(i)) for lowerServer. The two spellings of the server pointer can now drift apart. Doing the check inside the existing loop and extending the server's pointer, as serverVariables does with sptr + ids.Ptr("variables", name), avoids the second pass and the second pointer derivation.
There was a problem hiding this comment.
Folded into the existing loop in b23b370: serverNameClaims.claim takes the server's sptr and sites the report at sptr + ids.Ptr("name"), so there is one walk and one pointer derivation.
| // Error, as ConflictingOperationID is: `name` is the server's identity ("an | ||
| // optional unique string", OAS 3.2.1 §4.5.1), so the document's own claim is | ||
| // contradictory. Collisions this compiler makes, like DuplicateStatusKey's or | ||
| // a URL hint's, only warn. Only declared names in the document's servers |
There was a problem hiding this comment.
"Collisions this compiler makes, like DuplicateStatusKey's or a URL hint's, only warn" is not accurate for the hint half: a URL-hint collision reports nothing at all (TestDuplicateServerName_NotReported asserts that, and serverName says an emitter uniquifies). Only DuplicateStatusKey warns. Suggest "warn or are tolerated silently", or just drop the clause.
There was a problem hiding this comment.
Right, a hint collision is silent. The comment is rewritten in b23b370 to say that names equal only once canonicalized, and URL hints, are tolerated because an emitter uniquifies them.
| hintsCollide: true, | ||
| }, | ||
| { | ||
| name: "a declared name equal to another entry's hint", |
There was a problem hiding this comment.
This row never asserts that the declared name really equals the other entry's derived hint, which is the guard the neighbouring hintsCollide row added so it cannot pass vacuously. It currently holds only because CanonicalWords("https://api.example.com/v1") happens to be https_api_example_com_v_1 (I checked), and a change to the hint grammar would turn this into a row that tests nothing. Assert doc.Servers[0].Name.Hint == doc.Servers[1].Name.Canonical here.
There was a problem hiding this comment.
Added in b23b370. The row guard is now a collide function picking the two lowered words a row claims are equal. This row asserts Servers[0].Name.Hint == Servers[1].Name.Canonical (non-empty), so a change to the hint grammar reddens it instead of leaving a row that tests nothing.
|
Findings that have no line in the diff:
|
|
Follow-up notes from a second pass over the final state (head e912f7e with current Correction. My inline comment on the version gating said the error text cites OAS 3.2.1 §4.5.1. It does not: the diagnostic message carries no citation, and only the doc comment on 1. The severity rests on wording the spec does not make normative. 2. The title and body do not mark the exit-code change. 3. The exact-string choice is not pinned. 4. Two citations in the description do not point where they say. 5. The overlay test claims more than it checks. 6. A neighbouring comment contradicts the rule. |
OAS 3.2 calls a server's name "an optional unique string" with no RFC 2119 keyword, unlike operationId's MUST, and a YAML alias or merge key can repeat a name the document wrote only once. So a repeated name now warns rather than refusing the compile: a document that exits 0 today still does under the default --fail-on. The check runs in the lowerServers loop, extending the server pointer lowerServer already builds rather than deriving a second one, and drops the dead nil guard. Names are compared as exact strings; the tests now pin that names equal only once canonicalized (prod, Prod) stay silent, and that a declared name really equals the other entry's hint in the row that claims so. A 3.1 document is checked too, since its servers lower with the name it wrote. When only the earlier claim came from an overlay, the report sits at that entry and names the overlay as its source, since the overlay made the two collide. A fixture under testdata/openapi puts the warning under the harness order-invariance sweep.
|
Thanks for both passes. Everything is addressed in b23b370 and 63384ae (the latter keeps the Severity (second pass, 1). Now a warning. The 3.2 Breaking (2). With a warning, a document that exits 0 on Exact strings (3). Pinned by the Citations (4). The description now names Overlay sentence (5). Dropped. The test is now a three-case table, including your renamed-earlier ordering, and every case asserts what its name claims. Version gating (6). Kept on every version, because the lowering uses
Closing keyword.
Merge commit trailers. Noted: the squash body will be set by hand at merge time.
|
The parameter-list uniqueness check walked the source on its own. A second list rule, a server name repeated within one servers list, reads the same owners: the document, each path item, the fields beside a path item $ref, and each operation. Walking the source again for it would cost about a million allocations on an 11 MB description, so the walk becomes listRepeats, which hands each owner's lists to a rule per list: paramRepeats is the parameter rule, unchanged, and writtenItem names the path item whose lists a walk item writes, which the check had inline. Behaviour is unchanged.
The check ran where the document's servers lower, so a path item's or an operation's list, which lowers to no ir.Server, was never read. Those lists are in the source's model, and load already walks every list the source writes for the parameter rule. The check moves there as serverRepeats: the document's list, each path item's (the fields beside a path item $ref included) and each operation's, lowered or not, each checked once where it is written. Each repeat is now reported at itself and credited to the document that wrote it, as every other repeat rule reports. The lowering moved the report to the earlier entry when only that one came from an overlay, so beside an overlay's rename of the first entry, a name the base repeated was reported twice at the overlay's entry and never at the base's own. A list only a $ref reaches, under an x-* key or in another document, is left unchecked by both rules (#880).
The one conflict was the OpenAPI row of docs/ir-design.md, which #726 also edited. The row is main's with the Paths key clause put back before the webhook key's.
Summary
OpenAPI 3.2 gives a Server Object a
name, "an optional unique string to refer to the host designated by the URL". Two entries of one servers list could declare the same name, both lowered, and nothing said so (#631). This reports it.openapi/duplicate-server-name, a warning, besideopenapi/duplicate-parameterin the duplicate family.paths,webhooks,components/pathItemsor a callback, and the fields written beside a path item$ref), and each operation's. An entry whose name an earlier entry of the same list already has is reported once, at its own name key (…/servers/<i>/name), naming the first holder's. Both entries are kept; nothing is dropped or merged.prod/Prod,prod-eu/prod_eu) are not reported: an emitter uniquifies them, as it does a hint collision.name, but the parser reads it andserverNamelowers it, so the IR carries the repeat either way; load already warns that the version does not define the field (openapi/later-version-construct).Why load, in the walk the parameter rule already makes
walkSourceis the one pass that visits every list where the source writes it, whether or not it lowers, never through a$ref.openapi/duplicate-parameterchecks its lists there for the same reason (openapi: a $ref'd repeat in a path item's parameters can go unreported #840).ir.Server(they are kept underUnmodeled), so a check in the lowering of the document's list, where this rule first sat, cannot reach them.listRepeats, which hands each owner's lists to a rule per list:paramRepeats(unchanged) andserverRepeats. A walk of its own would cost about a million allocations on an 11 MB description; the shared walk allocates exactly what the parameter check did alone on it.pass/validate: a pass sees the IR, where path-item and operation servers are raw YAML and every finding carriesNoSource, so it could neither read those lists nor site a finding at the source's name key.Why a warning
The 3.2
namerow says "unique" with no RFC 2119 keyword, while the spec's uniqueness rules for a parameter list,operationIdand tag names are MUSTs. So this sits withopenapi/duplicate-enum-value(JSON Schema's SHOULD) rather than withopenapi/duplicate-parameter. A document that exits 0 onmainstill does under the default--fail-on error;--fail-on warningmakes the repeat fatal.Overlay attribution
Each repeat is credited to the document that wrote that entry, as any finding at a pointer is. Crediting the overlay whenever it wrote only the earlier entry was considered and rejected: with a base that repeats a name itself and an overlay renaming an earlier entry to it, that reports the overlay's entry twice and the base's own repeat never.
Deliberately not done
$refreaches, under anx-*key or in another document, is not checked, sincewalkSourcenever follows a$ref. The parameter rule misses thex-*case too; openapi: a list only a $ref reaches is never checked for repeats #880 covers both, andlistRepeatsand the code's doc say so.listRepeatsis where it would go.Test plan
go test ./compilers/openapi/internal/load -run Repeats.TestServerRepeats_ReportsEveryListTheSourceWrites: the document's list (two entries; three, one report per repeat; an alias; a merge key; an unnamed entry ahead keeping source indices), a path item's and its operation's lists, a component path item nothing mounts, a webhook, a component callback, a path item two paths mount (reported once, where written), the fields beside a path item$refmounted again by an alias (reported once), and a 3.1 document.TestServerRepeats_ReportsOnlyANameOneListRepeatsExactly: distinct names, entries with no name or an empty one, and one name in the document's, a path item's and two operations' lists. The parameter tests are unchanged.go test ./compilers/openapi -run 'DuplicateServerName|Overlay': openapi: two servers with the same name compile clean #631's repro compiled end to end, both servers lowering with their names; hint/hint, name/hint, case and separator collisions, each unreported and each first proving its lowered words really collide; and overlay attribution: an appended repeat credited to the overlay, a base's own repeat beside an overlay's rename credited to the base, and two appended entries.testdata/openapi/duplicate_server_names.yamlrepeats names in the document's list, a path item's and an operation's, under the harness order-invariance sweep;go run ./cmd/morphic-harnessreports itok./site; checking the fields beside a$refat every position an alias reaches, or at none; letting an empty name claim; overwriting the first holder; skipping the document's, a path item's or an operation's list; skipping inline path items; error severity; the short-list guard off by one; comparing canonical words; siting at the entry rather than its name; dropping the first holder from the message; the walk's panic label; and crediting the overlay as above. Reading the document's list throughGetServersrather than the field survives, as it must: the server that stands in has no name.morphic compileon openapi: two servers with the same name compile clean #631's repro exits 0 with the warning at/servers/1/name, and 1 under--fail-on warning.mainand this branch, since none repeats a server name; a positive control of three that do differs only by the new warnings.make fmt vet build, golangci-lint v2.14.0 (0 issues),make coverage(every statement covered),make bench-smoke.make fuzzis left to CI.Closes #631