Repository navigation
fix(compilers/openapi): report allOf conjuncts sharing no value - #1277
Wahbeh-Mohammad wants to merge 6 commits into
Conversation
Follow-up: open items, by scopeThe in-scope defects landed in
Decision needed
Borderline: deferred, same mechanism
Out of scope: file separately
Corrections to the PR descriptionThe description was written at
Verification state
|
OmarAlJarrah
left a comment
There was a problem hiding this comment.
Reviewed e80229c1337028a1e6e5ec65d9e6e5bf51038929 by morphic-reviewing-prs, which the follow-up comment noted had not been run on the opened PR.
I found no correctness defect: every report I could provoke was true. What is left is below: four test gaps, one message, one test name, the description, and follow-ups still to file. With the rows proposed below, the head passes and each planted defect goes red on its own row.
What was run
- Golden spec @319dc756: exactly the 3 warnings the issue names, and the IR identical to
mainoutsidediagnostics. - 1,838 OpenAPI documents compiled on
mainand on this head: no exit-code change at the default--fail-on, no panic, and 4 documents change, in diagnostics only. - A separate type-conjunction check over 5,803
allOfs in those documents and the golden spec: 7 empty positions, all reported, and no report it did not expect. It skips positions writingenumorconst. - About 11,000 generated schemas across 3.0, 3.1, 3.2 and
nullable-widening=false, scored by a JSON Schema evaluator and by the rule as an independent predicate: no false report, no schema reported twice, and none carrying both warnings. Four planted defects moved that score, so it reacts. - The co-declared union routes, positions beyond the PR's five, cross-document
$refs, depth pastMaxAllOfFlattenDepth, a two-order diff andmorphic-harness: clean. - 37 planted defects: 31 fail a named test, 5 survive (below), and 1 is equivalent.
Before merging
main has moved past this head, by #1191 among others. A trial merge with 608a5a46 is clean. go test ./compilers/... ./internal/harness/... ./internal/conformance/... ./internal/archtest/... ./cmd/morphic-census/... passed on a merge with 5b5c86b1, and archtest, schema and the e2e tests again on 608a5a46. That is not the full gate, so merge main in and let CI run. #1269 conflicts with this PR in allof.go and docs/compilers/openapi.md, and in recursion_test.go and the allof-inline-residue golden; its description expects this change, so whichever lands second resolves them.
| return nil | ||
| } | ||
| return []ir.Diagnostic{c.DiagAt(ir.SeverityWarning, diag.DegradedConstruct, comp.pointer, | ||
| "the conjuncts of this allOf share no value (%s), so no instance satisfies this schema, which lowers as written", |
There was a problem hiding this comment.
nit: this message contradicts the code it is filed under, which answers the "decision needed" in the follow-up comment.
DegradedConstruct is documented as a construct the compiler "could not carry into the IR as written" (diag.go), and this message ends "which lowers as written". morphic-openapi-compiler's diagnostic-text-names-what-fired says to "reuse a code only when its documented meaning fits".
The fix is the wording, not a new code. DisjointVisibility has a code of its own because Visibility{None: true} "is the exact answer and a shape the IR already has" (diag.go). The IR has no shape for "no instance", so a composition is the weaker shape DegradedConstruct describes. Keep the code and say that, for example:
the conjuncts of this allOf share no value (%s), so no instance satisfies this schema, which a composition cannot state
unhomed-keywords.golden.json and allof-disjoint-conjuncts.golden.json follow with -update.
There was a problem hiding this comment.
Kept the code and changed the wording, as you suggest, in ad4d47af. The message now ends "which a composition cannot state". The allof-disjoint-conjuncts, unhomed-keywords and (after the merge of main) allof-refinement goldens follow.
| out := declaredInstanceTypes(s) | ||
| entries := s.GetAllOf() | ||
| for _, b := range entries { | ||
| out &= r.branch(b) |
There was a problem hiding this comment.
nit: no test holds this conjunction. With out = r.branch(b) here, go test ./compilers/openapi/internal/schema/ ./compilers/openapi/e2e/ stays green, because each composition a test lands on has one entry and no type of its own. CLAUDE.md's "Break what a test claims to catch" applies: TestAllOf_DisjointConjunctsReportOnceAtTheSchema claims every spelling, and the rule says an entry's type is "conjoined with what its own allOf states".
T: {type: string, allOf: [{description: d}]} composed with {$ref: Obj} draws 1 report at this head and 0 with that change. Add this to disjointComponents:
TypedComposed: {type: string, allOf: [{description: d}]}and this row to the table. The head passes, and the change goes red on this row (go test ./compilers/openapi/internal/schema/ -run TestAllOf_):
{"a $ref to a composition writing a type",
`{allOf: [{$ref: '#/components/schemas/TypedComposed'}, ` + refObj + `]}`, "allOf/0: string, allOf/1: object"},There was a problem hiding this comment.
Added in 1a60c25c: TypedComposed: {type: string, allOf: [{description: d}]} among the components and the row "a $ref to a composition writing a type". With out = r.branch(b) planted, that row is the only one in TestAllOf_ to fail. It passes on the head.
| // a reported one from drawing a second report. Where nothing reports it, as for | ||
| // a null its own entries share, that is a miss. | ||
| func (r *conjunctTypes) schema(s *oas3.Schema) instanceTypes { | ||
| if s == nil || !r.steps.Spend(1) { |
There was a problem hiding this comment.
nit: the PR description calls dropping this nil check an equivalent mutant, and it is not. The guard keeps a boolean entry, or a $ref landing on no schema, from spending the budget. Without it {allOf: [300 × true, {$ref: Str}, {$ref: Obj}]} goes from 1 report to 0, and the suite stays green.
.claude/skills/morphic-reviewing-prs/references/review-probes.md#redundant-guard removes a guard only where a second reader covers it, and nothing else does here, so keep it and pin it. This row in TestAllOf_DisjointConjunctsReportOnceAtTheSchema passes on the head and goes red without the check:
{"boolean entries spend no budget",
`{allOf: [` + strings.Repeat(`true, `, 300) + refStr + `, ` + refObj + `]}`,
"allOf/300: string, allOf/301: object"},There was a problem hiding this comment.
Confirmed: dropping s == nil takes the 300-boolean case from 1 report to 0, so it is not equivalent. The guard stays, and the row "boolean entries spend no budget" pins it in 1a60c25c. With the check removed, that row alone fails.
| // contradicts nothing, so a lone `type: "null"` beside what takes its null away | ||
| // draws nil too. | ||
| func disjointConjuncts(c lowering.Ctx, s *oas3.Schema, comp allOfComposition) []string { | ||
| r := conjunctTypes{refs: c.Bindings(), steps: budget.New(budget.AtMost(maxNullConjuncts))} |
There was a problem hiding this comment.
nit: nothing holds this bound's value. budget.AtMost(16) and budget.AtMost(128) here both leave the suite green, while {allOf: [200 × {description: d}, {$ref: Str}, {$ref: Obj}]} goes from 1 report to 0. "a null read past its budget" stays silent either way, so it cannot tell. CLAUDE.md's "Break what a test claims to catch" applies again: a bound a test cannot see can be lowered with no failure.
This row in TestAllOf_DisjointConjunctsReportOnceAtTheSchema passes on the head and goes red on both:
{"a wide allOf the budget reaches",
`{allOf: [` + strings.Repeat(`{description: d}, `, 200) + refStr + `, ` + refObj + `]}`,
"allOf/200: string, allOf/201: object"},maxNullConjuncts - 1 is an equivalent mutant, so no row needs to hold the exact value: the null read spends at least one step more than this read, so it runs out first.
There was a problem hiding this comment.
The row "a wide allOf the budget reaches" is in 1a60c25c. Following your other comment on this line, the read now has its own bound, maxConjunctTypeReads (0f19a049), so the row holds both: it fails with maxConjunctTypeReads at 16 or 128, and with maxNullConjuncts at 128.
| " Cy: {allOf: [{$ref: '#/components/schemas/Cy'}, {$ref: '#/components/schemas/Cy'}]}\n", ""}, | ||
| {"an unresolved $ref", `{allOf: [{$ref: '#/components/schemas/Ghost'}, ` + refObj + `]}`, "", ""}, | ||
| {"a $ref landing on a boolean", `{allOf: [{$ref: '#/components/schemas/Yes'}, ` + refObj + `]}`, "", ""}, | ||
| {"a keyword beside a $ref", `{allOf: [{$ref: '#/components/schemas/Str', type: object}]}`, "", ""}, |
There was a problem hiding this comment.
nit: this row cannot fail, so it does not hold what it names. CLAUDE.md counts "a test that passes without asserting what its name claims" as a defect.
Conjoin the sibling into the read, return r.schema(refBranchTarget(r.refs, b)) & declaredInstanceTypes(b.GetSchema()) in branch, and this spec still reports nothing: the one conjunct reads empty, and len(names) < 2 silences it. The rest of the suite stays green too.
That change is a false report on 3.0, which ignores the keyword. {allOf: [{$ref: Props, type: string}, {type: object}]} over an untyped Props draws 0 reports at this head and 1 with the change. The table is 3.1 only, so this needs a test beside it. It passes on the head and goes red on the change:
// TestAllOf_KeywordBesideARefStatesNothing holds a keyword beside a $ref entry
// out of the reading where it decides the result: OpenAPI 3.0 ignores it, so an
// untyped target and an object still share every object.
func TestAllOf_KeywordBesideARefStatesNothing(t *testing.T) {
t.Parallel()
doc, _ := parseFull(t, openapitest.ComponentSpecVer("3.0.3", ` Props: {properties: {a: {type: string}}}
S: {allOf: [{$ref: '#/components/schemas/Props', type: string}, {type: object}]}
`))
assert.Empty(t, disjointReports(doc.Diagnostics))
}There was a problem hiding this comment.
Confirmed on e80229c1: with the sibling conjoined in branch, the schema and e2e tests stayed green. TestAllOf_KeywordBesideARefStatesNothing is in 1a60c25c as you wrote it, and the plant fails it. I also rewrote the 3.1 row so it can fail: {allOf: [{$ref: Props, type: string}, {type: object}]}. That row holds the stated limit, that the keyword is unread from 3.1 on too, and the same plant fails it. Both are cited in the rule's Tests list.
| // report: a conjunct writing no type states nothing, and neither does a | ||
| // boolean, an unresolved $ref or a keyword beside a $ref, a null both sides | ||
| // admit is a value they share, and a lone conjunct contradicts nothing. | ||
| func TestAllOf_ConjunctsSharingAValueStaySilent(t *testing.T) { |
There was a problem hiding this comment.
nit: the name says less than the table holds. In "a null beside an enum", "a null beside a const" and the false rows the conjuncts share no value; they stay silent because one conjunct narrows the instance types. The rows for an enum passing the allOf over and an allOf beside a $ref are never read at all. CONTRIBUTING.md says "A test is named for what it tests", and these rows came with e80229c1, after the name.
Name it for the report staying silent, such as TestAllOf_DisjointReportStaysSilent, and update its citation in the rule's Tests list.
There was a problem hiding this comment.
Renamed to TestAllOf_DisjointReportStaysSilent in 1a60c25c, with the rule's citation updated. The doc comment now names the lone narrowing conjunct and an allOf that does not compose. A probe confirmed that the enum-passes-over and allOf-beside-$ref rows never reach readAllOf.
| instanceObject instanceTypes = 1 << iota | ||
| instanceArray | ||
| instanceString | ||
| instanceNumber // integer too: every integer is a number |
There was a problem hiding this comment.
suggestion: the report says number for a schema that wrote integer: {allOf: [{type: integer}, {type: string}]} draws allOf/0: number, allOf/1: string, so a reader looking for number at allOf/0 finds nothing. It is documented and pinned, so it is no defect. An integer bit inside number would keep the written word and leave which sets are disjoint unchanged.
There was a problem hiding this comment.
Done in 0f19a049, using the kindSet that #1269 landed in admission.go rather than a second bit set. It already keeps kindInteger apart from kindFraction, so {type: integer} reads integer and {type: number} reads number, and disjointness is unchanged. A new row, "a number against an object", holds the spelling. It fails if the number entry is dropped from kindWords or if a matched kind is not consumed.
| return out | ||
| } | ||
|
|
||
| // conjunctTypes reads what schemas state of their instance types, within one |
There was a problem hiding this comment.
suggestion: maxNullConjuncts says it "bounds the walk below", and this read now shares it from another file under a name about nulls. A constant of its own, or a line there saying it bounds this read too, stops a change to one walk from moving the other unseen.
There was a problem hiding this comment.
The read now has a constant of its own, maxConjunctTypeReads (0f19a049), and maxNullConjuncts again bounds only its own walk. The new constant's doc comment says a report also needs the null read, which maxNullConjuncts bounds. The recursion entry in internal/archtest names the new bound.
|
nit: the description no longer matches the tree it describes.
"Reviews done" narrates the work, and two of its sentences depend on a comment beside the description. Fold what still holds into Summary and Test plan, so the description reads on its own. |
|
issue: the follow-ups this PR relies on are written down but not filed.
|
Main now lowers an allOf by what its conjuncts say, and keeps one whose conjuncts share no value a composition. Four files conflicted. allof.go keeps both new fields of allOfComposition: the disjoint report this branch reads and the branches main folds. diagUnmergedBranch takes main's split beside this branch's disjoint flag, so a branch excluding object still drops to an info where the schema carries the warning. recursion_test.go keeps both new cycles. The rules document keeps main's Pet example of a contradicted branch, over a Pet writing no type, and says the disjoint case is reported at the schema. A lone inline branch now refines rather than composing, so the fixtures this branch had reduced to one branch compose an untyped $ref instead: Contradicted in allof-inline-residue and M in the empty-allOf test. The tests main gave an object $ref beside a string branch compose an untyped one, since an object and a string share no value. Main's Disjoint case and its refinement row now draw one warning at the schema, and item 6 of allof-refinement says which report each contradiction gets. The conjunct reading stays apart from main's admission reading: that one reads a properties with no type as an object, which would report a string beside such a schema though a string satisfies both.
The report of conjuncts sharing no value ended "which lowers as written", against the degraded-construct code it is filed under, which marks a construct lowered to a weaker shape. The IR has no shape for a schema no instance satisfies, so the composition it lowers to is that weaker shape, and the message now says so.
The report read a schema's types into a set of its own in which an integer was a number, so {allOf: [{type: integer}, {type: string}]} named allOf/0 a number though it wrote integer. It now reads them into the kindSet the refinement reading uses, which holds an integer apart from a number with a fraction, so the report names what the source wrote and the two readings share one set of kinds.
The read also takes a budget of its own, maxConjunctTypeReads, rather than sharing maxNullConjuncts with the walk that constant documents, so moving one bound no longer moves the other.
Four parts of the read could change with the suite green: the conjunction of an entry's own type with its allOf, the guard that keeps a boolean entry from spending the budget, the budget's value, and the keyword beside a $ref entry, whose row could not fail because a lone conjunct contradicts nothing. Each now has a case that fails without it, the last on 3.0, where the keyword is ignored, and on 3.1 with a second conjunct. The silent cases are named for the report staying silent, since several of them share no value and some are never read.
|
Replies to the review body and the two comments on the description and the follow-ups. The head is now Merging
Folding into The description. Rewritten from the head as Summary, Limits and Test plan. It now says the exit code changes under Numbers on this head.
Follow-ups. Not filed yet, and so not cited in the rule or in |
Closes #1215
Summary
An
allOfwhose conjuncts share no value admits no instance. Before this change it was reported only when an inline branch wrote a non-objecttype; the same contradiction written as atypebeside theallOfagainst a$ref, or as two disjoint$refs, compiled in silence.compilers/openapi/internal/schema/allof_disjoint.goreads, from the source schemas, which JSON instance types each conjunct states, and reports a schema where they share none:openapi/degraded-constructwarning at the schema's own pointer, naming each conjunct that narrows the instance types as the source writes it:the conjuncts of this allOf share no value (allOf/0: string, allOf/1: object), so no instance satisfies this schema, which a composition cannot state. The code is kept rather than adding one: the IR has no shape for a schema no instance satisfies, so the composition it lowers to is the weaker shapeDegradedConstructdescribes.nullablerule reads it, under the nullable-widening policy where that is on. A null read that runs past its budget is undecided and reports nothing.IRVersiondoes not move, and no diagnostic code is added. The exit code changes only under--fail-on warning: the issue'sTwoRefsexits 0 onmainand 1 here, and 0 on both at the default.The read works on source schemas, not lowered nodes, because a
Modelcannot say whether its source wrotetype: object, and{properties: …}alone admits a string. For the same reason it stays apart from the admission readingallof-refinementelects a principal by, which takes apropertieswith notypeto claim an object: reading it that way here would report a string beside such a schema, though a string satisfies both. The two sharekindSet, so anintegerconjunct is namedinteger, held within anumber.The read recurses through
allOfentries and$reftargets, so it spends a budget of its own,maxConjunctTypeReads, throughbudget.Budget, and is listed inTestLoweringRecursion_IsOnlyTheKnownCycles.This branch has
mainmerged in at3188471d, which brings #1269. Since #1269 anallOfof one inline branch refines rather than composing, so the fixtures that show a contradicted branch compose a$refto a schema writing notype, which a string satisfies. TheDisjointcase ofallof-refinement, two inline branches ofintegerandstring, now draws one warning at the schema with an info at each branch, and item 6 of that rule says which report each contradiction gets.Limits
Each is stated in
docs/compilers/openapi.md#allof-disjoint-conjuncts.enum,const,format,oneOforanyOfcreate states no instance type, so it draws no report; twoenums sharing no member keep each branch with an info.$refentry is not read at any version. OpenAPI 3.0 ignores it; from 3.1 on, where it applies, reading it is left open, so{allOf: [{$ref: Str, type: object}]}admits nothing on 3.1 and draws no report.allOfempties what it states states nothing, which keeps a schema composing a reported one from a second report. The cost is a miss on a conjunct admitting only a null its own entries share, or one nested pastMaxAllOfFlattenDepth.type: "null"beside afalse,enumorconstis silent. The null reading is the whole schema's, so two or more narrowing conjuncts beside afalse,enumorconstare reported though that entry alone takes away the null they share.allOfthat does not compose, beside a$refor passed over for anenumorconst, is not read.Test plan
make gateexits 0 on1a60c25c: 0 lint issues with golangci-lint v2.14.0, the 49 planted findings drawn, all 18628 statements covered.allof-disjoint-conjuncts:OwnType,TwoRefsandInlineeach draw one warning at the schema,Inline/allOf/1one info, andSharedNullnothing; exit 0.openapi.json@7e292b90,morphic validateagainstmain@3188471d: exactly 3 new warnings and no other diagnostic change, atzero-trust-gateway_api-response-common-failurepropertieserrorsandmessagesand at theacm/custom_trust_storedelete4XXresponse, the positions the issue names.jq -S 'del(.diagnostics)'ofcompileis byte-identical tomain's. Both builds exit 1, on 9 errors this revision draws onmaintoo.sweep.pyovertestdataagainstmain@3188471d: 3 of 231 specs differ, in diagnostics only, with the IR outsidediagnosticsidentical and every exit code unchanged:allof-disjoint-conjuncts(new),allof-refinement(Disjoint, above) andunhomed-keywords(AllOfWithScalarType, atype: stringbeside anallOfof an object).typewith its ownallOf, the guard that keeps a boolean entry from spending the budget, the budget's value (16 and 128 formaxConjunctTypeReads, 128 formaxNullConjuncts), a keyword beside a$refread on 3.0 and on 3.1, and the spelling ofnumberandinteger.propertieswith notypeas an object, as the admission reading does, failsTestAllOf_DisjointReportStaysSilent.TestAllOf_DisjointConjunctsIgnoreDeclarationOrderdeclares the composing schema before and after what it names and compares the reports.e80229c1: four parts of the read no test held, a message at odds with the code it is filed under, a test name narrower than its cases and a stale description. Each is addressed on this head, the first two by the cases and the message above.