Skip to content

Commit d112f14

Browse files
committed
Conformance v1.5.0: ASCII trim, non-string job rule, results.json
1 parent ed58775 commit d112f14

14 files changed

Lines changed: 893 additions & 43 deletions
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
#!/usr/bin/env python3
2+
"""Validate a conformance results.json (ADR-0032).
3+
4+
usage: check-conformance-results.py <results.json> <vendor-dir> <expected-conformance-version>
5+
6+
Fails (exit 1) when results.json
7+
* does not conform to schema/results.schema.json,
8+
* reports conformance_version other than the pinned one,
9+
* has a `fail` case,
10+
* omits a binding whose code is present in this repository (bindings are derived from the
11+
source tree, never hand-written), or lists one whose code is absent,
12+
* omits a section (or a case id) that the manifest puts in scope for the reported bindings.
13+
"""
14+
import json
15+
import os
16+
import sys
17+
18+
from jsonschema import Draft202012Validator
19+
20+
PACKAGE_ROOT = "src/main/java/com/babelqueue"
21+
22+
# Sub-package of the core -> binding it carries (schema enum). Any transport package added to
23+
# the core must be listed here, or the check below fails on the unknown package.
24+
PACKAGE_BINDINGS = {
25+
"otel": "otel",
26+
"redis": "redis",
27+
"amqp": "amqp",
28+
"rabbitmq": "amqp",
29+
"sqs": "sqs",
30+
"azureservicebus": "asb",
31+
"asb": "asb",
32+
"pulsar": "pulsar",
33+
"kafka": "kafka",
34+
"artemis": "artemis",
35+
}
36+
37+
# Sub-packages that are not bindings.
38+
NON_BINDING_PACKAGES = {"gdpr", "idempotency", "outbox", "schema"}
39+
40+
41+
def case_ids(section, body):
42+
"""The case-id rule of the conformance README ("Sections, scopes and exceptions")."""
43+
def named(prefix, fallback, items):
44+
# `fallback` maps the 0-based index of an unnamed case to its id.
45+
return [prefix + (c["name"] if isinstance(c, dict) and isinstance(c.get("name"), str) else fallback(i))
46+
for i, c in enumerate(items)]
47+
48+
if isinstance(body, list):
49+
return named("", lambda i: f"{section}#{i}", body)
50+
if not isinstance(body, dict):
51+
return []
52+
ids = []
53+
if isinstance(body.get("cases"), list):
54+
ids += named("", lambda i: f"{section}#{i}", body["cases"])
55+
for key, value in body.items():
56+
if key in ("description", "schema", "cases") or not isinstance(value, dict):
57+
continue
58+
if isinstance(value.get("cases"), list):
59+
ids += named(key + "/", str, value["cases"])
60+
else:
61+
ids.append(key)
62+
return ids
63+
64+
65+
def derive_bindings():
66+
"""Bindings carried by the source tree; fails on a package it cannot classify."""
67+
errors = []
68+
bindings = set()
69+
for entry in sorted(os.listdir(PACKAGE_ROOT)):
70+
if not os.path.isdir(os.path.join(PACKAGE_ROOT, entry)) or entry in NON_BINDING_PACKAGES:
71+
continue
72+
if entry in PACKAGE_BINDINGS:
73+
bindings.add(PACKAGE_BINDINGS[entry])
74+
else:
75+
errors.append(f"unknown package {PACKAGE_ROOT}/{entry}: add it to PACKAGE_BINDINGS (a transport) "
76+
"or NON_BINDING_PACKAGES in this script")
77+
return sorted(bindings), errors
78+
79+
80+
def main():
81+
results_path, vendor, pinned = sys.argv[1:4]
82+
results = json.load(open(results_path))
83+
manifest = json.load(open(os.path.join(vendor, "manifest.json")))
84+
schema = json.load(open(os.path.join(vendor, "schema", "results.schema.json")))
85+
errors = [e.message for e in Draft202012Validator(schema).iter_errors(results)]
86+
87+
if results.get("conformance_version") != pinned:
88+
errors.append(f"conformance_version {results.get('conformance_version')!r} != pinned {pinned!r}")
89+
90+
derived, derive_errors = derive_bindings()
91+
errors += derive_errors
92+
if sorted(results.get("bindings", [])) != derived:
93+
errors.append(f"bindings {sorted(results.get('bindings', []))} != bindings derived from the source tree {derived}")
94+
95+
carried = {"core", *derived}
96+
sections = results.get("sections", {})
97+
for name, meta in manifest["sections"].items():
98+
if meta["scope"] not in carried:
99+
if name in sections:
100+
errors.append(f"section {name!r} (scope {meta['scope']}) is out of scope and must be absent")
101+
continue
102+
if name not in sections:
103+
errors.append(f"in-scope section {name!r} is missing from results.json")
104+
continue
105+
missing = [i for i in case_ids(name, manifest.get(name)) if i not in sections[name]]
106+
if missing:
107+
errors.append(f"section {name!r} is missing cases: {', '.join(missing)}")
108+
for name in sections:
109+
if name not in manifest["sections"]:
110+
errors.append(f"section {name!r} is not defined by the manifest")
111+
112+
failed = [f"{s}/{i}" for s, cs in sections.items() for i, r in cs.items() if r.get("status") == "fail"]
113+
if failed:
114+
errors.append("failing cases: " + ", ".join(failed))
115+
116+
if errors:
117+
print("results.json check FAILED:", file=sys.stderr)
118+
for e in errors:
119+
print(" - " + e, file=sys.stderr)
120+
return 1
121+
total = sum(len(cs) for cs in sections.values())
122+
print(f"results.json OK: {len(sections)} sections, {total} cases, bindings={results['bindings']}")
123+
return 0
124+
125+
126+
if __name__ == "__main__":
127+
sys.exit(main())

‎.github/workflows/ci.yml‎

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ on:
88
permissions:
99
contents: read
1010

11+
env:
12+
# The conformance suite release this repo is vendored from and must report against.
13+
BQ_CONFORMANCE_VERSION: "1.5.0"
14+
1115
jobs:
1216
test:
1317
runs-on: ubuntu-latest
@@ -24,22 +28,36 @@ jobs:
2428
java-version: ${{ matrix.java }}
2529
cache: maven
2630

31+
# The conformance runner writes results.json (ADR-0032) when BQ_CONFORMANCE_RESULTS is set.
2732
- name: Test
2833
run: mvn -B --no-transfer-progress verify
34+
env:
35+
BQ_CONFORMANCE_RESULTS: ${{ runner.temp }}/results.json
36+
37+
- uses: actions/setup-python@v6
38+
with:
39+
python-version: "3.12"
40+
41+
# Schema-valid, no `fail`, bindings derived from the source tree, and every in-scope
42+
# section and case present (an absent transport or section breaks CI).
43+
- name: Validate results.json
44+
run: |
45+
python -m pip install --quiet jsonschema==4.23.0
46+
python -I .github/scripts/check-conformance-results.py "$RUNNER_TEMP/results.json" \
47+
src/test/resources/conformance "$BQ_CONFORMANCE_VERSION"
2948
3049
conformance:
3150
name: Conformance suite in sync
3251
runs-on: ubuntu-latest
3352
steps:
3453
- uses: actions/checkout@v7
35-
- name: Verify vendored conformance matches the canonical suite
54+
# fetch.sh downloads the release asset, verifies it against the release's SHA256SUMS and,
55+
# with --check, fails if the vendored copy differs. It is taken from the same pinned tag.
56+
- name: Verify vendored conformance matches the pinned release
3657
run: |
37-
git clone --depth 1 --branch v1.4.0 https://github.com/BabelQueue/conformance.git "$RUNNER_TEMP/conformance"
38-
diff -u "$RUNNER_TEMP/conformance/CONFORMANCE_VERSION" "src/test/resources/conformance/CONFORMANCE_VERSION"
39-
diff -ru "$RUNNER_TEMP/conformance/manifest.json" "src/test/resources/conformance/manifest.json"
40-
diff -ru "$RUNNER_TEMP/conformance/fixtures" "src/test/resources/conformance/fixtures"
41-
diff -ru "$RUNNER_TEMP/conformance/schema" "src/test/resources/conformance/schema"
42-
echo "Vendored conformance is in sync with the canonical suite."
58+
curl -fsSL -o "$RUNNER_TEMP/fetch.sh" \
59+
"https://raw.githubusercontent.com/BabelQueue/conformance/v${BQ_CONFORMANCE_VERSION}/fetch.sh"
60+
bash "$RUNNER_TEMP/fetch.sh" --check "$BQ_CONFORMANCE_VERSION" src/test/resources/conformance
4361
4462
ci-green:
4563
name: CI green

‎.github/workflows/release.yml‎

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,49 @@ on:
77
permissions:
88
contents: write
99

10+
env:
11+
BQ_CONFORMANCE_VERSION: "1.5.0"
12+
1013
jobs:
14+
# Secret-free: runs the tests, produces results.json and validates it. The PyPI package it
15+
# installs never shares a job with the GPG key or the Central credentials.
16+
verify:
17+
runs-on: ubuntu-latest
18+
permissions:
19+
contents: read
20+
steps:
21+
- uses: actions/checkout@v7
22+
23+
- uses: actions/setup-java@v6
24+
with:
25+
distribution: temurin
26+
java-version: "17"
27+
cache: maven
28+
29+
- name: Run tests
30+
run: mvn -B --no-transfer-progress verify
31+
env:
32+
BQ_CONFORMANCE_RESULTS: ${{ runner.temp }}/results.json
33+
34+
- uses: actions/setup-python@v6
35+
with:
36+
python-version: "3.12"
37+
38+
- name: Validate results.json
39+
run: |
40+
python3 -m pip install --quiet jsonschema==4.23.0
41+
python3 -I .github/scripts/check-conformance-results.py "$RUNNER_TEMP/results.json" \
42+
src/test/resources/conformance "$BQ_CONFORMANCE_VERSION"
43+
cp "$RUNNER_TEMP/results.json" results.json
44+
45+
- uses: actions/upload-artifact@v4
46+
with:
47+
name: results
48+
path: results.json
49+
if-no-files-found: error
50+
1151
release:
52+
needs: verify
1253
runs-on: ubuntu-latest
1354
steps:
1455
- uses: actions/checkout@v7
@@ -34,11 +75,12 @@ jobs:
3475
curl -fsSL https://archive.apache.org/dist/maven/maven-3/3.9.11/binaries/apache-maven-3.9.11-bin.tar.gz | tar xz -C "$RUNNER_TEMP"
3576
echo "$RUNNER_TEMP/apache-maven-3.9.11/bin" >> "$GITHUB_PATH"
3677
37-
- name: Run tests
38-
run: mvn -B --no-transfer-progress -v && mvn -B --no-transfer-progress verify
78+
- uses: actions/download-artifact@v4
79+
with:
80+
name: results
3981

4082
- name: Publish to Maven Central
41-
run: mvn -B --no-transfer-progress -Prelease -DskipTests deploy
83+
run: mvn -B --no-transfer-progress -v && mvn -B --no-transfer-progress -Prelease -DskipTests deploy
4284
env:
4385
CENTRAL_TOKEN_USERNAME: ${{ secrets.CENTRAL_TOKEN_USERNAME }}
4486
CENTRAL_TOKEN_PASSWORD: ${{ secrets.CENTRAL_TOKEN_PASSWORD }}
@@ -48,3 +90,4 @@ jobs:
4890
uses: softprops/action-gh-release@v3
4991
with:
5092
generate_release_notes: true
93+
files: results.json

‎src/main/java/com/babelqueue/EnvelopeCodec.java‎

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -88,14 +88,14 @@ public static Envelope make(
8888
Map<String, Object> metaExtras = meta == null ? null : new LinkedHashMap<>(meta);
8989
rejectInvalidExtras(metaExtras, KNOWN_META, FORBIDDEN_META, "/meta/");
9090

91-
String resolvedUrn = urn == null ? "" : urn.strip();
91+
String resolvedUrn = urn == null ? "" : stripAscii(urn);
9292
if (resolvedUrn.isEmpty()) {
9393
throw new BabelQueueException(
9494
"A polyglot message must expose a stable, non-empty URN so consumers "
9595
+ "can identify it without any class name.");
9696
}
9797

98-
String trace = traceId == null ? "" : traceId.strip();
98+
String trace = traceId == null ? "" : stripAscii(traceId);
9999
if (trace.isEmpty()) {
100100
trace = UUID.randomUUID().toString();
101101
}
@@ -245,7 +245,15 @@ public static Envelope decode(String raw, Consumer<String> onWarning) {
245245

246246
// job wins unless it is blank (closed ASCII set); then the urn alias is the identity. The
247247
// urn key itself is never retained; it only warns when it is dropped in favour of job.
248-
String job = asString(map.get("job"));
248+
// A non-string job (null, number, boolean, object, array) is ignored with a warning and
249+
// never retained (ADR-0032 section 6); a blank string job falls back silently.
250+
Object rawJob = map.get("job");
251+
String job = asString(rawJob);
252+
if (map.containsKey("job") && !(rawJob instanceof String)) {
253+
boolean aliasUsable = !isBlankAscii(asString(map.get("urn")));
254+
onWarning.accept("BabelQueue: ignored /job because it is not a string"
255+
+ (aliasUsable ? "; falling back to the urn alias" : ""));
256+
}
249257
if (map.containsKey("urn")) {
250258
String alias = asString(map.get("urn"));
251259
if (isBlankAscii(job) && alias != null) {
@@ -293,9 +301,20 @@ public static String urn(Envelope envelope) {
293301
* (space, {@code \t}, {@code \n}, {@code \v}, {@code \f}, {@code \r}) — the suite's "blank"
294302
* (ADR-0031 (c)). Deliberately not {@link String#isBlank()}: Unicode spaces such as U+2003 are
295303
* not blank, for cross-SDK parity (GR-5).
304+
*
305+
* @param value the string to test, or {@code null}
306+
* @return {@code true} when {@code value} is {@code null}, empty or ASCII-whitespace only
296307
*/
297-
static boolean isBlankAscii(String value) {
298-
return value == null || stripAscii(value).isEmpty();
308+
public static boolean isBlankAscii(String value) {
309+
if (value == null) {
310+
return true;
311+
}
312+
for (int i = 0; i < value.length(); i++) {
313+
if (!isAsciiSpace(value.charAt(i))) {
314+
return false;
315+
}
316+
}
317+
return true;
299318
}
300319

301320
/** Trim only the closed ASCII whitespace set from both ends (see {@link #isBlankAscii}). */
@@ -330,7 +349,7 @@ public static boolean accepts(Envelope envelope) {
330349
if (envelope.data() == null) {
331350
return false;
332351
}
333-
return envelope.traceId() != null && !envelope.traceId().isBlank();
352+
return !isBlankAscii(envelope.traceId());
334353
}
335354

336355
private static Envelope empty() {

‎src/main/java/com/babelqueue/Redrive.java‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ private static Item process(Transport transport, String dlq, Options opts, Reser
262262
return new Item(messageId, env.traceId(), env.job(), reason, dlq, null, false, false);
263263
}
264264

265-
String target = opts.toQueue() != null && !opts.toQueue().isBlank()
265+
String target = !EnvelopeCodec.isBlankAscii(opts.toQueue())
266266
? opts.toQueue()
267267
: sourceQueueOf(env);
268268

@@ -315,7 +315,7 @@ private static void republish(Transport transport, String queue, String body, Re
315315

316316
private static String sourceQueueOf(Envelope env) {
317317
DeadLetter dl = env.deadLetter();
318-
if (dl != null && dl.originalQueue() != null && !dl.originalQueue().isBlank()) {
318+
if (dl != null && !EnvelopeCodec.isBlankAscii(dl.originalQueue())) {
319319
return dl.originalQueue();
320320
}
321321
return env.meta() == null ? null : env.meta().queue();

‎src/main/java/com/babelqueue/outbox/Outbox.java‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ public String write(Envelope envelope) {
7171
*/
7272
private static String queueOf(Envelope envelope) {
7373
Meta meta = envelope.meta();
74-
if (meta != null && meta.queue() != null && !meta.queue().isBlank()) {
74+
if (meta != null && !EnvelopeCodec.isBlankAscii(meta.queue())) {
7575
return meta.queue();
7676
}
7777
return DEFAULT_QUEUE;

0 commit comments

Comments
 (0)