Skip to content

Commit db3fecc

Browse files
committed
Visual improvements for RFC levels
1 parent 10d921d commit db3fecc

12 files changed

Lines changed: 117 additions & 2 deletions

File tree

.github/workflows/probe.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ jobs:
155155
'expected': expected, 'got': got,
156156
'connectionState': conn, 'reason': reason,
157157
'scored': scored,
158+
'rfcLevel': r.get('rfcLevel', 'Must'),
158159
'durationMs': r.get('durationMs', 0),
159160
'rawRequest': r.get('rawRequest'),
160161
'rawResponse': r.get('rawResponse'),

AGENTS.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ yield return new TestCase
4141
},
4242

4343
// OPTIONAL fields
44+
RfcLevel = RfcLevel.Must, // Must (default) | Should | May | OughtTo | NotApplicable
4445
RfcReference = "RFC 9112 §5.1", // Use § not "Section". Omit if no RFC applies.
4546
Scored = true, // Default true. Set false for MAY/informational tests.
4647
AllowConnectionClose = false, // On Expected. See validation rules below.
@@ -114,8 +115,18 @@ Use for pass/warn/fail logic, timeout acceptance, or multi-outcome tests.
114115
private static byte[] MakeRequest(string request) => Encoding.ASCII.GetBytes(request);
115116
```
116117

118+
**RfcLevel values:**
119+
- `RfcLevel.Must` — (default) RFC says MUST / MUST NOT. Only set explicitly if you want to be clear.
120+
- `RfcLevel.Should` — RFC says SHOULD / SHOULD NOT / RECOMMENDED.
121+
- `RfcLevel.May` — RFC says MAY / OPTIONAL. Both behaviors are compliant.
122+
- `RfcLevel.OughtTo` — RFC uses "ought to" (weaker than SHOULD).
123+
- `RfcLevel.NotApplicable` — No single RFC 2119 keyword applies (best-practice / defensive tests).
124+
125+
Check the [RFC Requirement Dashboard](docs/content/docs/rfc-requirement-dashboard.md) for classification guidance and to verify your assignment matches the existing pattern.
126+
117127
**Critical rules:**
118128
- NEVER set `AllowConnectionClose = true` for MUST-400 requirements where the RFC explicitly says "respond with 400".
129+
- Set `RfcLevel` to match the RFC 2119 keyword in the relevant RFC quote. Default is `Must` — only set explicitly for non-Must tests.
119130
- Set `Scored = false` only for MAY-level or purely informational tests.
120131
- Always use `ctx.HostHeader` (not a hardcoded host) in payloads.
121132
- Tests are auto-discovered — no registration step needed. The `GetTestCases()` yield return is sufficient.

docs/content/add-a-test.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ yield return new TestCase
2121
Id = "COMP-MY-TEST",
2222
Description = "Description of what the test checks",
2323
Category = TestCategory.Compliance,
24+
RfcLevel = RfcLevel.Must, // Must (default) | Should | May | OughtTo | NotApplicable
2425
RfcReference = "RFC 9112 §X.X",
2526

2627
PayloadFactory = ctx => MakeRequest(
@@ -86,6 +87,7 @@ Expected = new ExpectedBehavior
8687

8788
### Key conventions
8889

90+
- Set `RfcLevel` to match the RFC 2119 keyword for the requirement being tested. The default is `Must` — only set it explicitly for non-Must tests. Available values: `Must`, `Should`, `May`, `OughtTo`, `NotApplicable`. Check the [RFC Requirement Dashboard]({{< relref "docs/rfc-requirement-dashboard" >}}) for classification guidance.
8991
- Use `Exact(400)` with **no** `AllowConnectionClose` for strict MUST-400 requirements (SP-BEFORE-COLON, MISSING-HOST, DUPLICATE-HOST, OBS-FOLD, CR-ONLY).
9092
- Set `AllowConnectionClose = true` only when connection close is an acceptable alternative to a status code.
9193
- Set `Scored = false` for MAY-level or informational tests.

docs/content/add-with-ai-agent.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,11 @@ Point your AI agent at the repository and reference the `AGENTS.md` file. It con
2525

2626
For a new **test**, the agent will:
2727

28-
1. Add a `yield return new TestCase { ... }` block to the correct suite file
28+
1. Add a `yield return new TestCase { ... }` block to the correct suite file, including the correct `RfcLevel` (`Must`, `Should`, `May`, `OughtTo`, or `NotApplicable`)
2929
2. Add a docs URL mapping entry (if the test is `COMP-*` or `RFC*` prefixed)
3030
3. Create a documentation page under `docs/content/docs/{category}/`
3131
4. Add a card to the category index page
32+
5. Add a row to the RFC Requirement Dashboard
3233

3334
For a new **framework**, the agent will:
3435

@@ -41,4 +42,5 @@ For a new **framework**, the agent will:
4142

4243
- The `AGENTS.md` file includes verification checklists — make sure the agent runs them before submitting
4344
- No changes to CI workflows are needed for either task; tests and servers are auto-discovered
44-
- For tests, the agent should check the RFC to determine the correct requirement level (MUST/SHOULD/MAY) and validation pattern
45+
- For tests, the agent should check the RFC to determine the correct `RfcLevel` (MUST/SHOULD/MAY/"ought to"/N/A) and set it on the `TestCase`. The default is `Must` — only set explicitly for non-Must tests
46+
- The agent should add a row to the [RFC Requirement Dashboard](docs/content/docs/rfc-requirement-dashboard.md) and update all counts

docs/static/probe/render.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ window.ProbeRender = (function () {
7979
+ '.probe-table thead .probe-sticky-col{z-index:3}'
8080
+ 'tr[data-expected-row]{background:#f0f3f6;border-bottom:2px solid #d0d7de !important}'
8181
+ 'tr[data-expected-row] .probe-sticky-col{background:#f0f3f6}'
82+
+ 'tr[data-rfc-level-row]{background:#f6f8fa}'
83+
+ 'tr[data-rfc-level-row] .probe-sticky-col{background:#f6f8fa}'
84+
+ 'html.dark tr[data-rfc-level-row]{background:#1e242c}'
85+
+ 'html.dark tr[data-rfc-level-row] .probe-sticky-col{background:#1e242c}'
8286
+ '.probe-server-row:hover .probe-sticky-col{background:#eef1f5}'
8387
+ '.probe-server-row.probe-row-active .probe-sticky-col{background:#c8ddf0}'
8488
// Sticky first column — dark
@@ -428,6 +432,22 @@ window.ProbeRender = (function () {
428432
return v === 'Pass' ? PASS_BG : v === 'Warn' ? WARN_BG : FAIL_BG;
429433
}
430434

435+
function rfcLevelBg(level) {
436+
if (level === 'Must') return '#cf222e';
437+
if (level === 'Should') return '#9a6700';
438+
if (level === 'OughtTo') return '#b08800';
439+
if (level === 'May') return '#0969da';
440+
return '#656d76'; // NotApplicable
441+
}
442+
443+
function rfcLevelLabel(level) {
444+
if (level === 'Must') return 'MUST';
445+
if (level === 'Should') return 'SHOULD';
446+
if (level === 'OughtTo') return 'OUGHT TO';
447+
if (level === 'May') return 'MAY';
448+
return 'N/A';
449+
}
450+
431451
function buildLookups(servers) {
432452
servers = filterBlacklisted(servers);
433453
var names = servers.map(function (sv) { return sv.name; }).sort();
@@ -598,6 +618,19 @@ window.ProbeRender = (function () {
598618
});
599619
t += '</tr></thead><tbody>';
600620

621+
// RFC Level row
622+
t += '<tr data-rfc-level-row>';
623+
t += '<td class="probe-sticky-col" style="padding:6px 10px;font-weight:700;font-size:12px;color:#656d76;">RFC Level</td>';
624+
orderedTests.forEach(function (tid, i) {
625+
var first = lookup[names[0]][tid];
626+
var level = first.rfcLevel || 'Must';
627+
var sepCls = i === unscoredStart ? ' probe-unscored-sep' : '';
628+
var bg = rfcLevelBg(level);
629+
var label = rfcLevelLabel(level);
630+
t += '<td data-test-label="' + escapeAttr(shortLabels[i]) + '" class="' + sepCls + '" style="text-align:center;padding:3px 4px;">' + pill(bg, label) + '</td>';
631+
});
632+
t += '</tr>';
633+
601634
// Expected row
602635
t += '<tr data-expected-row>';
603636
t += '<td class="probe-sticky-col" style="padding:6px 10px;font-weight:700;font-size:12px;color:#656d76;">Expected</td>';

src/Http11Probe.Cli/Reporting/JsonReporter.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ public static string Generate(TestRunReport report)
3636
category = r.TestCase.Category.ToString(),
3737
rfcReference = r.TestCase.RfcReference,
3838
scored = r.TestCase.Scored,
39+
rfcLevel = r.TestCase.RfcLevel.ToString(),
3940
expected = r.TestCase.Expected.GetDescription(),
4041
verdict = r.Verdict.ToString(),
4142
statusCode = r.Response?.StatusCode,
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
namespace Http11Probe.TestCases;
2+
3+
public enum RfcLevel
4+
{
5+
Must,
6+
Should,
7+
May,
8+
OughtTo,
9+
NotApplicable
10+
}

src/Http11Probe/TestCases/Suites/ComplianceSuite.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ public static IEnumerable<TestCase> GetTestCases()
1313
Id = "COMP-BASELINE",
1414
Description = "Valid GET request — confirms server is reachable",
1515
Category = TestCategory.Compliance,
16+
RfcLevel = RfcLevel.NotApplicable,
1617
PayloadFactory = ctx => MakeRequest($"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\n\r\n"),
1718
Expected = new ExpectedBehavior
1819
{
@@ -25,6 +26,7 @@ public static IEnumerable<TestCase> GetTestCases()
2526
Id = "RFC9112-2.2-BARE-LF-REQUEST-LINE",
2627
Description = "Bare LF in request line should be rejected, but MAY be accepted",
2728
Category = TestCategory.Compliance,
29+
RfcLevel = RfcLevel.May,
2830
RfcReference = "RFC 9112 §2.2",
2931
PayloadFactory = ctx => MakeRequest($"GET / HTTP/1.1\nHost: {ctx.HostHeader}\r\n\r\n"),
3032
Expected = new ExpectedBehavior
@@ -46,6 +48,7 @@ public static IEnumerable<TestCase> GetTestCases()
4648
Id = "RFC9112-2.2-BARE-LF-HEADER",
4749
Description = "Bare LF in header should be rejected, but MAY be accepted",
4850
Category = TestCategory.Compliance,
51+
RfcLevel = RfcLevel.May,
4952
RfcReference = "RFC 9112 §2.2",
5053
PayloadFactory = ctx => MakeRequest($"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\nX-Test: value\r\n\r\n"),
5154
Expected = new ExpectedBehavior
@@ -93,6 +96,7 @@ public static IEnumerable<TestCase> GetTestCases()
9396
Id = "RFC9112-3-MULTI-SP-REQUEST-LINE",
9497
Description = "Multiple spaces between request-line components — SHOULD reject but MAY parse leniently",
9598
Category = TestCategory.Compliance,
99+
RfcLevel = RfcLevel.Should,
96100
RfcReference = "RFC 9112 §3",
97101
PayloadFactory = ctx => MakeRequest($"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\n\r\n"),
98102
Expected = new ExpectedBehavior
@@ -130,6 +134,7 @@ public static IEnumerable<TestCase> GetTestCases()
130134
Id = "RFC9112-2.3-INVALID-VERSION",
131135
Description = "Invalid HTTP version must be rejected",
132136
Category = TestCategory.Compliance,
137+
RfcLevel = RfcLevel.Should,
133138
RfcReference = "RFC 9112 §2.3",
134139
PayloadFactory = ctx => MakeRequest($"GET / HTTP/9.9\r\nHost: {ctx.HostHeader}\r\n\r\n"),
135140
Expected = new ExpectedBehavior
@@ -192,6 +197,7 @@ public static IEnumerable<TestCase> GetTestCases()
192197
Id = "RFC9112-3.2-FRAGMENT-IN-TARGET",
193198
Description = "Fragment (#) in request-target — not part of origin-form grammar",
194199
Category = TestCategory.Compliance,
200+
RfcLevel = RfcLevel.Should,
195201
RfcReference = "RFC 9112 §3.2",
196202
PayloadFactory = ctx => MakeRequest($"GET /path#frag HTTP/1.1\r\nHost: {ctx.HostHeader}\r\n\r\n"),
197203
Expected = new ExpectedBehavior
@@ -216,6 +222,7 @@ public static IEnumerable<TestCase> GetTestCases()
216222
Id = "RFC9112-2.3-HTTP09-REQUEST",
217223
Description = "HTTP/0.9 request (no version) must be rejected",
218224
Category = TestCategory.Compliance,
225+
RfcLevel = RfcLevel.Should,
219226
RfcReference = "RFC 9112 §2.3",
220227
PayloadFactory = _ => MakeRequest("GET /\r\n"),
221228
Expected = new ExpectedBehavior
@@ -409,6 +416,7 @@ public static IEnumerable<TestCase> GetTestCases()
409416
Id = "COMP-LEADING-CRLF",
410417
Description = "Leading CRLF before request-line — server may ignore per RFC",
411418
Category = TestCategory.Compliance,
419+
RfcLevel = RfcLevel.Should,
412420
RfcReference = "RFC 9112 §2.2",
413421
PayloadFactory = ctx => MakeRequest($"\r\n\r\nGET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\n\r\n"),
414422
Expected = new ExpectedBehavior
@@ -432,6 +440,7 @@ public static IEnumerable<TestCase> GetTestCases()
432440
Id = "COMP-ABSOLUTE-FORM",
433441
Description = "Absolute-form request-target — server should accept per RFC",
434442
Category = TestCategory.Compliance,
443+
RfcLevel = RfcLevel.Should,
435444
Scored = false,
436445
RfcReference = "RFC 9112 §3.2.2",
437446
PayloadFactory = ctx => MakeRequest($"GET http://{ctx.HostHeader}/ HTTP/1.1\r\nHost: {ctx.HostHeader}\r\n\r\n"),
@@ -456,6 +465,7 @@ public static IEnumerable<TestCase> GetTestCases()
456465
Id = "COMP-METHOD-CASE",
457466
Description = "Lowercase method 'get' — methods are case-sensitive per RFC",
458467
Category = TestCategory.Compliance,
468+
RfcLevel = RfcLevel.Should,
459469
RfcReference = "RFC 9110 §9.1",
460470
PayloadFactory = ctx => MakeRequest($"get / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\n\r\n"),
461471
Expected = new ExpectedBehavior
@@ -684,6 +694,7 @@ public static IEnumerable<TestCase> GetTestCases()
684694
Id = "COMP-METHOD-CONNECT",
685695
Description = "CONNECT to an origin server must be rejected",
686696
Category = TestCategory.Compliance,
697+
RfcLevel = RfcLevel.Should,
687698
RfcReference = "RFC 9110 §9.3.6",
688699
PayloadFactory = _ => MakeRequest(
689700
"CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n"),
@@ -708,6 +719,7 @@ public static IEnumerable<TestCase> GetTestCases()
708719
Id = "COMP-EXPECT-UNKNOWN",
709720
Description = "Unknown Expect value should be rejected with 417",
710721
Category = TestCategory.Compliance,
722+
RfcLevel = RfcLevel.May,
711723
RfcReference = "RFC 9110 §10.1.1",
712724
PayloadFactory = ctx => MakeRequest(
713725
$"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\nExpect: 200-ok\r\n\r\n"),
@@ -735,6 +747,7 @@ public static IEnumerable<TestCase> GetTestCases()
735747
Id = "COMP-GET-WITH-CL-BODY",
736748
Description = "GET with Content-Length and body — semantically unusual",
737749
Category = TestCategory.Compliance,
750+
RfcLevel = RfcLevel.May,
738751
RfcReference = "RFC 9110 §9.3.1",
739752
Scored = false,
740753
PayloadFactory = ctx => MakeRequest(
@@ -816,6 +829,7 @@ public static IEnumerable<TestCase> GetTestCases()
816829
Id = "COMP-METHOD-TRACE",
817830
Description = "TRACE request — should be disabled in production",
818831
Category = TestCategory.Compliance,
832+
RfcLevel = RfcLevel.Should,
819833
RfcReference = "RFC 9110 §9.3.8",
820834
Scored = false,
821835
PayloadFactory = ctx => MakeRequest(
@@ -857,6 +871,7 @@ public static IEnumerable<TestCase> GetTestCases()
857871
Id = "COMP-REQUEST-LINE-TAB",
858872
Description = "Tab as request-line delimiter — SHOULD reject but MAY parse on whitespace",
859873
Category = TestCategory.Compliance,
874+
RfcLevel = RfcLevel.Should,
860875
RfcReference = "RFC 9112 §3",
861876
PayloadFactory = ctx => MakeRequest($"GET\t/ HTTP/1.1\r\nHost: {ctx.HostHeader}\r\n\r\n"),
862877
Expected = new ExpectedBehavior
@@ -944,6 +959,7 @@ public static IEnumerable<TestCase> GetTestCases()
944959
Id = "COMP-HTTP10-DEFAULT-CLOSE",
945960
Description = "HTTP/1.0 without keep-alive — server should close connection after response",
946961
Category = TestCategory.Compliance,
962+
RfcLevel = RfcLevel.Should,
947963
RfcReference = "RFC 9112 §9.3",
948964
PayloadFactory = ctx => MakeRequest(
949965
$"GET / HTTP/1.0\r\nHost: {ctx.HostHeader}\r\n\r\n"),
@@ -966,6 +982,7 @@ public static IEnumerable<TestCase> GetTestCases()
966982
Id = "COMP-HTTP10-NO-HOST",
967983
Description = "HTTP/1.0 without Host header — valid per HTTP/1.0",
968984
Category = TestCategory.Compliance,
985+
RfcLevel = RfcLevel.May,
969986
Scored = false,
970987
RfcReference = "RFC 9112 §3.2",
971988
PayloadFactory = _ => MakeRequest("GET / HTTP/1.0\r\n\r\n"),
@@ -990,6 +1007,7 @@ public static IEnumerable<TestCase> GetTestCases()
9901007
Id = "COMP-HTTP12-VERSION",
9911008
Description = "HTTP/1.2 — higher minor version should be accepted as HTTP/1.x compatible",
9921009
Category = TestCategory.Compliance,
1010+
RfcLevel = RfcLevel.May,
9931011
Scored = false,
9941012
RfcReference = "RFC 9112 §2.3",
9951013
PayloadFactory = ctx => MakeRequest(
@@ -1013,6 +1031,7 @@ public static IEnumerable<TestCase> GetTestCases()
10131031
Id = "COMP-TRACE-WITH-BODY",
10141032
Description = "TRACE with Content-Length body should be rejected",
10151033
Category = TestCategory.Compliance,
1034+
RfcLevel = RfcLevel.Should,
10161035
Scored = false,
10171036
RfcReference = "RFC 9110 §9.3.8",
10181037
PayloadFactory = ctx => MakeRequest(
@@ -1094,6 +1113,7 @@ public static IEnumerable<TestCase> GetTestCases()
10941113
Id = "COMP-UNKNOWN-METHOD",
10951114
Description = "Unrecognized method should be rejected with 501 or 405",
10961115
Category = TestCategory.Compliance,
1116+
RfcLevel = RfcLevel.Should,
10971117
RfcReference = "RFC 9110 §9.1",
10981118
PayloadFactory = ctx => MakeRequest(
10991119
$"FOOBAR / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\n\r\n"),
@@ -1211,6 +1231,7 @@ public static IEnumerable<TestCase> GetTestCases()
12111231
Id = "COMP-OPTIONS-ALLOW",
12121232
Description = "OPTIONS response should include Allow header listing supported methods",
12131233
Category = TestCategory.Compliance,
1234+
RfcLevel = RfcLevel.Should,
12141235
RfcReference = "RFC 9110 §9.3.7",
12151236
PayloadFactory = ctx => MakeRequest(
12161237
$"OPTIONS / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\n\r\n"),
@@ -1233,6 +1254,7 @@ public static IEnumerable<TestCase> GetTestCases()
12331254
Id = "COMP-CONTENT-TYPE",
12341255
Description = "Response with content should include Content-Type header",
12351256
Category = TestCategory.Compliance,
1257+
RfcLevel = RfcLevel.Should,
12361258
RfcReference = "RFC 9110 §8.3",
12371259
PayloadFactory = ctx => MakeRequest(
12381260
$"GET / HTTP/1.1\r\nHost: {ctx.HostHeader}\r\n\r\n"),

0 commit comments

Comments
 (0)