{{ message }}
Repository navigation
Conversation
aepfli
force-pushed
the
feat/provider-tck-report
branch
from
August 24, 2026 20:01
d3214d5 to
7021126
Compare
aepfli
force-pushed
the
feat/provider-tck-report
branch
2 times, most recently
from
September 11, 2026 11:17
8e88c9b to
14191cb
Compare
aepfli
force-pushed
the
feat/provider-tck
branch
from
September 11, 2026 14:23
2a4800d to
7ebfbb2
Compare
aepfli
force-pushed
the
feat/provider-tck-report
branch
from
September 11, 2026 14:23
14191cb to
4752d27
Compare
aepfli
force-pushed
the
feat/provider-tck-report
branch
16 times, most recently
from
September 16, 2026 12:08
c889079 to
d2f8e3a
Compare
aepfli
force-pushed
the
feat/provider-tck
branch
from
September 16, 2026 19:45
814fe39 to
6e4f910
Compare
aepfli
force-pushed
the
feat/provider-tck-report
branch
from
September 16, 2026 19:45
d2f8e3a to
89c9131
Compare
aepfli
force-pushed
the
feat/provider-tck-report
branch
2 times, most recently
from
October 2, 2026 08:57
84f703d to
42a59f3
Compare
aepfli
force-pushed
the
feat/provider-tck-report
branch
from
October 8, 2026 08:20
42a59f3 to
4ff7b45
Compare
aepfli
force-pushed
the
feat/provider-tck
branch
from
October 9, 2026 07:38
8572e28 to
891a5a9
Compare
aepfli
force-pushed
the
feat/provider-tck-report
branch
from
October 9, 2026 07:38
4ff7b45 to
6cc113a
Compare
aepfli
force-pushed
the
feat/provider-tck
branch
from
October 14, 2026 13:21
891a5a9 to
e76d937
Compare
aepfli
force-pushed
the
feat/provider-tck-report
branch
2 times, most recently
from
October 14, 2026 14:31
56f9810 to
5c560e0
Compare
aepfli
force-pushed
the
feat/provider-tck
branch
from
October 16, 2026 15:40
4c47a42 to
907e4a3
Compare
aepfli
force-pushed
the
feat/provider-tck-report
branch
2 times, most recently
from
October 16, 2026 15:58
cfecea2 to
630ec2b
Compare
Collapsed from 29 commits when this branch was replayed onto the comment cull. The cull rewrote prose across the same files these commits touch, so a commit-by-commit rebase produced a conflict in most of them, several carrying real code rather than wording -- the kind of sequence where a wrong resolution is easy and silent. One squashed replay gave one conflict pass instead, and this repository squash-merges, so the granularity was never going to survive anyway. The original subjects, oldest first: - feat(tck): emit a machine-readable conformance report - feat(tck): identify an outline scenario by its Examples row - fix(tck): give a failed capability a reason, and stop claiming untested ones - refactor(tck): carry results in Cucumber Messages - feat(tck): record which Cucumber Messages release produced the stream - refactor(tck): rename @strict-numeric-typing to @numeric-coercion - fix(tck): stop a reserved capability reaching a report's declaration - feat(tck): fail a run that did not execute the canonical set - fix(tck): record the spec revision the assets came from - fix(tck): read the spec revision from the submodule, not the environment - fix(tck): name a canonical feature relative to the asset directory - fix(tck): emit knownDeviations, which the report discarded - fix(tck): stop emitting declaration.notApplicable - fix(tck): follow the pin into the recorded spec revision - fix(tck): record the pin @disabled-flags arrived on - style(tck): format the example-table reader - fix(tck): record the revision the pin moved to - fix(tck): always emit backend and controlApi, which the schema requires - refactor(tck)!: drop the PROVIDER_ prefix from the report-directory variable - fix(tck): record the revision the pin moved to - docs(tck): stop saying CI sets the report directory, because it does not - fix(tck): record the revision the pin moved to - fix(tck): record the revision the pin moved to - fix(tck): say in the report which kind of skip a skip was - docs(tck): record the pin, and stop illustrating the discouraged shape - docs(tck): document reports in the rewritten README - docs(tck): link the report's rules to Appendix F rather than restating them - fix(tck): follow the narrowed mismatch matrix into the report's tallies - fix(tck): refresh the recorded spec revision and record the revision-check gaps Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
aepfli
force-pushed
the
feat/provider-tck-report
branch
from
October 16, 2026 16:06
630ec2b to
e13f111
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Stacked on #1606 — base is
feat/provider-tck, so review only the commits above it. Part ofopen-feature/spec#424; the schema the envelope
validates against is open-feature/spec#425, and the
appendix defining the capability vocabulary is
open-feature/spec#423. Tracking issue for the suite
itself is open-feature/spec#417.
The last two commits are a capability rename the specification made
(
dc4d7ae8) and a defect in what thislibrary declares; both are described below.
Results are carried in Cucumber Messages, not in a format
we maintain. The first version of this PR defined one: a
scenarios[]list, a four-value outcomeenum, an
examplefield for Scenario Outline row identity, and a git tree hash for the executedassets. That was the wrong call, and the last commit undoes it. Everything in that list is specified
by Cucumber Messages already, and a second definition is a second thing to version and two places
for the same fact to disagree.
Set
TCK_REPORT_DIRand each suite writes two files:in-memory.ndjsonis a Cucumber Messages stream:Meta, then per feature aSource, aGherkinDocumentand aPickleper scenario, then aTestCaseper pickle,TestRunStarted, aTestCaseStarted/TestStepFinished/TestCaseFinishedtriple per scenario, andTestRunFinished.in-memory.jsonis the envelope from the reshaped schema: what was tested, what the providerdeclares, and where the results are with a
sha256digest over them. Twenty-nine scenarios,twenty-nine pickles, twenty-nine test cases, twenty-nine outcomes.
Building the stream
jest-cucumber has no output layer at all — no reporter, no JSON, nothing; it is a binding from
Gherkin to Jest and that is all. The stream is therefore constructed by the harness, and two things
it already did make that cheap rather than painful.
It plans every scenario before the run — file order, then outline examples, which is the order
autoBindStepsdefines them in — and asserts each definition against the plan as it arrives. Thatplan is exactly where
PickleandTestCasemessages come from. And it owns thetestandtest.skipcalls, because jest-cucumber accepts thedescribe/testpair it makes them through,so an outcome is recorded at the point the decision is made rather than scraped back out of a
reporter afterwards: a skip where the capability gate chooses it, a pass or failure where the test
body settles.
@cucumber/gherkincompiles theSource,GherkinDocumentandPicklemessages straight from thefeature files, so no message here is hand-rolled and no id is invented.
@cucumber/messagesjoins it as a peer dependency and supplies the generated types; jest-cucumber already depends on
both transitively, so no adopter gains an install.
Each planned scenario is bound to one compiled pickle positionally, and the binding is checked
rather than assumed: jest-cucumber substitutes an outline row into the scenario title and so does
the pickle compiler, so the two strings must agree or the suite fails instead of reporting an
outcome against a scenario that did not run.
One
TestStepper test case, not one per Gherkin step. jest-cucumber runs a whole scenario as asingle Jest test and reports one outcome for it; it never says which step failed. A step per Gherkin
step would mean inventing per-step results — marking them all failed over-claims, marking one of
them failed picks a step at random — so the stream carries the granularity the runner actually has.
The steps are in the stream on the
Pickle, with the outline row already substituted into them, andjest-cucumber's failure message happens to name the step it was on:
{"status":"FAILED","duration":{"seconds":0,"nanos":8000000}, "message":"Failing step: \"the error-code should be \"FLAG_NOT_FOUND\"\"\n\nStep arguments: [\"FLAG_NOT_FOUND\"]\n\nError: error-code was 'GENERAL', expected 'FLAG_NOT_FOUND'"}What was deleted, and what replaced it
scenarios[]and the four-value outcome enum. A scenario is aPicklewith aTestCase, andits outcome is
TestStepFinished.testStepResult.status. A capability-gated scenario isSKIPPEDwith the reason on the result — whichever kind of skip it was.
example.Pickle.astNodeIdsidentifies an outline row exactly: the second id is theTableRowin theGherkinDocument, and the document carries its cells. Four implementations hadeach invented an answer to this and they had diverged — a bare scenario name, a runner's example id,
jest-cucumber's expanded title, and a line number matched back against a re-parse of the
Examplestables.
errors.feature's eleven type-mismatch rows share a name and share their expanded title,because that outline's title carries no placeholders, so the AST node is the only thing that tells
them apart:
25is theScenario Outline; the second id is the row. No separator, ordering or escaping rulehas to be agreed between four languages for that to resolve, which is what a name-mangling
convention would have required.
src/lib/examples.ts, which re-parsed each feature file to recoverthe rows, is deleted with the field — the rows now come from the same parse that produced the
pickles, and are kept only so a harness diagnostic can name a row the way the feature file writes
it.
tck.assetsTree. The stream carries every executed feature file verbatim as aSource, whichidentifies the assets by content, covers only what actually ran, and sits under the report's digest.
That is strictly better than a tree hash of the whole asset directory, so
write-revision.jsstopscomputing one.
tck.specRevisionstays: it is what identifies the two artifacts the stream does notcarry,
canonical-flags.jsonandcontrol-api.yaml.not-applicableas a per-scenario outcome. It moves intodeclaration.notApplicable, which iswhere the reshaped schema puts it and where it belongs.
@numeric-coercionis unsatisfiable inJavaScript because the language has no integer type — a fact about the language, not about a run —
so stating it once with a reason is better than repeating it on every scenario carrying the tag:
The reason is now required, because a reader has no other way to tell an impossibility from an
excuse.
TckOptions.notApplicablebecomes a map from capability to reason, andNO_INTEGER_TYPE_IN_JAVASCRIPTis exported so every JavaScript report words this one the same way.The gate is unchanged: those scenarios are skipped with the reason in the test name exactly as
before, and the scenario is
SKIPPEDin the stream with the impossibility spelled out on theresult. What changed is what is declared, not what runs.
The per-capability roll-up, which the reshaped schema does not have. It is derivable from the
declaration and the stream, and the vacuous passes it used to guard against are avoided by counting
test cases that ran rather than tags present: every scenario carrying a capability can be skipped
for a different one — both scenarios in
events.featurecarry@eventsas well as@staleor@configuration-change. The README says sofor a consumer computing one, and also says what a roll-up is not: scenarios carrying no capability
tag are mandatory and roll up into nothing, so a provider can fail a mandatory scenario with every
capability intact.
What stays OpenFeature's, and why
A Messages stream cannot say what it was a test of. No standard results format identifies the
subject under test, so the envelope carries the four things that are genuinely ours:
provider— what the provider calls itself through its own metadata, not the suite name. Thesuite name is chosen to read well in a failure message (
flagd-rpc), which makes it theconfiguration, and it is reported as such. One provider with two materially different modes
produces two reports that are not interchangeable.
sdk— read at run time from the installed@openfeature/server-sdkrather than declared. Adeclared version is a second place to be wrong: the report would keep claiming 1.17.0 after a
dependency bump moved the code underneath it.
tck— which implementation asked the questions, and which revision of the artifacts.declaration— the capability set the provider claims. This is an input to reading theresults, not a summary of them, which is why it cannot be derived from the stream and has to be
stated. The stream says a scenario was skipped; only the declaration says whether that is because
the provider declines the capability it needed, or because the capability cannot hold for it at
all. Given the declaration and a scenario's tags — both present — the reason for every skip
follows without being transported per scenario.
results.digestcovers the.ndjson, so a consumer that fetched the two separately can tell thatwhat it has is what the envelope describes. The stream is written first and the envelope second, so
an envelope never names results that are not there.
Every scenario appears exactly once
That is still the load-bearing property, and the reason the report exists. Appendix F requires that
a scenario skipped for an undeclared capability is reported as skipped and never as passed, and
the report is what makes the rule checkable by a consumer instead of dependent on each runner's
summary being trustworthy — godog counts capability skips in its passed tally, which is what
motivated the format.
Two checks, both on every run rather than only in a test:
cannot reach the branch that records a pass, structurally.
planned scenario is missing from the stream or recorded twice. It is now keyed on the pickle,
which is unique per outline row — keyed on the name, dropping one of eleven rows and recording
another twice tallied as eleven expected and eleven recorded, and the check that exists to catch a
dropped scenario would have passed.
A scenario is registered when it is defined, not when it runs, so one Jest never finished still
appears — as a
FAILEDwhose message says it never reported an outcome. A run filtered with-tistherefore partial by construction and should not be published; that is documented.
The plan is positional, not keyed on the scenario name. Gherkin permits tags on an individual
Examplesblock, so two rows of one outline can differ in whether the capability gate stops them; aname-keyed plan would gate all of them together and the rows that ran would vanish. No canonical
feature does that today, so it is latent rather than live, but it is the same defect that was found
and fixed in the Go implementation.
@strict-numeric-typingis now@numeric-coercion, and the rule it states is differentThe specification renamed the tag and corrected the rule
(
dc4d7ae8), so the submodule moves to thatcommit and
Capability.StrictNumericTypingbecomesCapability.NumericCoercion.The old name asserted the stricter rule — never coerce between integer and float — and that forbids
a case the specification wants to work. flagd is implementing an accepted
numeric coercion ADR
(open-feature/flagd#1996) whose rule is
lossless coercion is permitted; lossy coercion must fail with
TYPE_MISMATCH:10.0requestedas an integer succeeds,
0.5does not. The existing scenario survives the difference only by luck,because it asks about
0.5. flagd's own testbed is gaining@numeric-coercionscenarios too, sokeeping a second name for one property would have left the reference implementation and the
specification disagreeing about what a rule is called.
No scenario was added here, and two gaps were recorded by the appendix rather than closed. One has
since closed:
The lossless half has no scenario, in any language.Closed. The canonical flag setgained
integral-float-flag, anderrors.featurenow carries three@numeric-coercionscenarios — the lossy one,
10.0requested as an integer, and10requested as a float — allthree of which a declarer must satisfy. That is what stops the shortcut of rejecting every float
and calling it strictness, and two of the four OFREP adoptions were taking it. It changes nothing
for JavaScript, where the capability is refused outright for the reason above.
distinguishes a 64-bit integer accessor from a 32-bit one, and flagd's testbed tags the 32-bit-only
scenarios
@int32-bounded. Appendix F draws the same boundary from the other side: the 2^31 − 1precision scenario is untagged and mandatory because every language's accessor can ask for it,
and the 2^53 − 1 one carries
@large-integersbecause only some can. Mandatory-versus-gatedinstead of two tags. Nothing above 2^53 − 1 is asked for at all, and JavaScript is the reason — a
double cannot represent it. What is genuinely open is the negative half: nothing asserts what a
provider owes a value that does not fit the requested accessor, which is
spec#430 and unsettled.
JavaScript is the language where this capability is
notApplicablerather than merely undeclared, soNO_INTEGER_TYPE_IN_JAVASCRIPTis the sentence a report carries — and it now has to be true ofboth halves, which is a slightly different claim than before. It is: with one
numbertype andonly
getNumberDetails, there is no lossless coercion to permit, because10.0and10are thesame value and nothing is narrowed, and no lossy one to reject, because
0.5asked for as an Integeris indistinguishable from a perfectly valid Float request. What fails here is the premise both halves
rest on — that an integer request is distinguishable from a float one — not either half.
A reserved capability must not be declarable
@cachingis in the vocabulary but no scenario carries it.@targetingwas in the same position when this was written and stopped being so at spec26362f85, once scenarios started carrying it — which is the shape a reserved tag is meant to have: a placeholder that either grows scenarios or is removed, never a claim a report can carry indefinitely. The mechanism below is unchanged and still has one member to enforce; the examples show both. The reportschema now states that such a tag must not be declared and must not appear in
declaration.declared: a capability no executed scenario carries cannot produce a skip, so it playsno part in interpreting the results, and listing it invites a reader to believe it was verified when
nothing examined it.
This was a live defect, not a tidy-up. A real Java conformance report asserts both tags as
declared — not by anyone's decision, but because that adoption declares "every capability except X"
and picks up every reserved tag in the vocabulary on the way past. The default here had the same
shape (
ALL_CAPABILITIESminus what cannot apply), so any JS suite that omittedcapabilitieswould have published the same claim.
RESERVED_CAPABILITIESis the one list, andDECLARABLE_CAPABILITIES— the vocabulary without it— is what
capabilitiesdefaults to, so "declare everything" cannot mean "declare things nothingtested".
features it actually ran and fails if a reservation has expired. An out-of-date list would make
a newly testable capability permanently unclaimable, which is the same mistake pointing the other
way.
capabilitiesornotApplicableis refused, notsilently filtered. The schema permits a warning or a failed run; this is a suite whose whole
point is that unverified claims are loud, and a console line competes with Jest's own output, is
invisible in the log of a green CI build, and would leave the adopter believing a claim was made
that the report then dropped. The fix is a one-line edit, so refusing costs nothing.
notApplicableis refused for the same reason: with no scenario to be inapplicable to, thestatement is about nothing.
declaration.declared. The options check is wherean adopter hears about it; that filter is the document being unable to carry the claim by any
other route into a declared set, and it is what
report.spec.tsasserts.Capability resolution moves out of
runProviderTckintoresolveCapabilities, so the three rulesthat exist to stop a wrong report can be tested without Jest —
options.spec.tsis new.Three schema changes since this was opened
All three are in spec#425 and this branch emits
against them.
backend.controlApiis required. A provider with no backend still had its flag statemanipulated somehow, and which of the two ways that was is the single most important thing a reader
needs in order to know what the results are worth. The old shape made the whole
backendblockomissible, which made the one value most worth knowing —
in-process, the case the enum exists for— the one that could never appear. The control states it; nothing infers it.
summaryis required, and its issue link is not. A deviation with no proseis a line of tags that tells a reader nothing; a deviation with no filed issue is an honest state to
be in, and requiring one encourages filing a placeholder.
provoked. It explains the skips that come from an undeclared capability. It does not explain a skip
the implementation refuses —
@numeric-coercionhere, becausetypeof 10andtypeof 0.5areboth
'number'and no JS provider can be asked for an integer as distinct from a float — which is aproperty of the language rather than of the provider. A reader inferring "the provider declined"
from that absence is wrong in the direction that matters. Appendix F records which capabilities
this applies to per language, and the results payload carries the distinction per skip.
Verification
Run against Node 18.19.1.
npx jest --config libs/shared/tck/jest.config.ts— 6 suites, 85 passed, 5 skipped. Thefive skips are the capability-gated scenarios, as before.
npx nx lint tck— clean, including@nx/dependency-checksfor the new peer dependency.npx tsc -p libs/shared/tck/tsconfig.spec.json --noEmit— clean.@numeric-coercionand no@strict-numeric-typing, andsrc/lib/revision.tsreports8374621763f04a3d0785a911f12035db4794179e, which is the submodule'sHEADand what the envelope'stck.specRevisionsays.declaration.declaredis["@configuration-change", "@events", "@object"], and carries noreserved tag.
declaration.notApplicableis gone — the schema'sdeclarationobject isadditionalProperties: falseoverdeclaredalone, and@numeric-coercionis now refused by theimplementation rather than declared inapplicable by each adopter, so there is nothing left for the
field to carry.
report.spec.tsasserts the emitted declaration has no such property.ajv(
ajv/dist/2020), a Draft 2020-12 validator, andresults.digestmatches asha256of the.ndjsonbeside it.library's idea of it.
@cucumber/messagesshipsschema/*.json; all 23 files are loaded intoajvand every one of the 156 envelopes is checked againstEnvelope.json, which isadditionalProperties: falsethroughout.messages.spec.tsdoes this on every run, so amalformed message is a test failure rather than something a consumer discovers.
in-memory{"PASSED": 24, "SKIPPED": 5}multi-provider{"PASSED": 8, "FAILED": 16, "SKIPPED": 5}Both add up to 29, the scenario count of the canonical features (17 in
errorscounting outlineexamples, 7 in
evaluation, 2 inevents, 3 inlifecycle), and themulti-providernumbers matchJest's own tally exactly.
The gated skips are truthful. Resolving pickle → test case → attempt → step result the way a
consumer has to, all five gated scenarios in the
in-memoryrun areSKIPPED, each with thecapabilities that gated it named on the result, and none is
PASSED. The@numeric-coerciononereads
requires @numeric-coercion, which cannot hold for this provider: JavaScript has no integer type…, and the other four read…which this provider does not declare. The same holds in themulti-providerrun, where 16 scenarios failed around them.Row identity resolves end to end. The eleven rows of the type-mismatch matrix carry eleven distinct
astNodeIds[1], each resolving in theGherkinDocumentto its ownTableRow— lines 28–31, 35–37and 41–44 of
errors.feature, matching the file's threeExamplesblocks in order:Every pickle's
tagsmatch the tags the harness gated on, feature-level tags andExamples-blocktags included, which is what lets a consumer recover the capability behind a skip from the stream
and the declaration alone.
Honest caveats:
multi-providernumbers were not re-measured after the last two commits. Neither commitchanges a scenario count or an outcome, so they should still hold, but they are from an earlier
run. They are also from a one-off run with
testPathIgnorePatternsoverridden on the commandline.
jest.config.tsstill excludes that suite and this PR does not change that; itis the only thing that exercises the failure path end to end, and it reproduces the known SDK bug
(open-feature/js-sdk#1452) exactly.
formatkeywords —ajv-formatsis not installed in thisworkspace. Neither document emits a field with a
formatconstraint (noprovenance, noprovider.repository), so nothing was skipped in practice. Anything adding those fields latershould re-check with formats on.
nx test provider-tckstill cannot run in my environment: itspullSpecstep shells out togit submodule update --init, and this checkout is a git worktree whose.gitfile holds aWindows path the WSL git cannot follow. Jest was run directly.
node scripts/write-revision.jswas run and reproduces the committed
revision.tsbyte for byte..ndjsonis 84 KB for 29 scenarios, most of it the four feature sources. That is the cost ofthe stream being self-contained, and it is why the envelope references the results rather than
inlining them.
Findings
JSON writer and no message emitter in its
dist/, so any language binding of Cucumber that wantsto report results has to build them itself. Of the four TCK implementations this was the only one
where the results format had to be constructed from scratch rather than configured.
scenarioNameTemplateto Scenario Outline examples. It uses theexpanded example title instead, so the reason a skipped example was skipped could not be shown.
Worked around by composing the name in the harness; that fix lives in feat(provider-tck): add the JavaScript conformance suite for OpenFeature providers #1606. Worth reporting
upstream.
Examplestable. It substitutes each row into the outline's stepsand title and keeps only the result, so the row a scenario came from cannot be recovered from what
it hands back. Compiling the feature with
@cucumber/gherkingives it back properly, as pickleAST node ids, which is what the report now uses.
loadFeatures(glob)returns features in the glob's order with no file identity. In practicethe order was neither alphabetical nor file order, which made the suite's own output ordering
arbitrary between features. Enumerating the directory fixes both.
MultiProviderreports its metadata name as_MultiProvider. Cosmetic, but it iswhat appears in a conformance report, and the leading underscore looks like it leaked from an
internal name rather than being chosen.