Skip to content

Commit 2b2fa79

Browse files
committed
feat(ci): enforce lowerCamelCase and max depth in reference.conf
Add a CI gate that scans common/src/main/resources/reference.conf and fails the build when any key violates lowerCamelCase (^[a-z][a-zA-Z0-9]*$ per dot-separated segment) or exceeds the maximum hierarchy depth (5). Array element keys are validated the same way; each array step counts as one depth level — e.g. an inner field at `rate.limiter.rpc[].component` is depth 5. Parsing is delegated to pyhocon, the reference Python HOCON implementation. It returns a fully-merged ConfigTree where dotted-form keys expand into nested objects — the same canonical key set Typesafe Config and ConfigBeanFactory see at runtime — and handles triple-strings, substitutions, includes, +=, and block comments without us re-implementing the grammar. Four legacy PBFT* keys are grandfathered via an in-script allowlist so the gate fails only on new violations. A consolidated GHA error annotation lists every offending key, and sys.exit(1) drives step failure. The script also accepts `--debug` to print every parsed key with its depth (trailing `/` marks namespace intermediates) for manual verification against the source file. Runs as a new step in the existing checkstyle job of pr-check.yml (setup-python + `pip install pyhocon`), so no extra runner spin-up.
1 parent 381d369 commit 2b2fa79

2 files changed

Lines changed: 242 additions & 0 deletions

File tree

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
#!/usr/bin/env python3
2+
"""Validate java-tron reference.conf key names and hierarchy depth.
3+
4+
Rules enforced:
5+
1. Every user-defined segment of every key path must match ^[a-z][a-zA-Z0-9]*$
6+
(lowerCamelCase: starts lowercase, letters/digits only).
7+
2. Total path depth must be <= MAX_DEPTH (5). Each list/array step counts
8+
as one additional level. For example `rate.limiter.http[].component`
9+
is 5 levels deep (rate=1, limiter=2, http=3, []=4, component=5).
10+
3. ALLOWLIST entries are exempt from the format rule (legacy keys that ship
11+
in user configs; renaming would break compatibility).
12+
13+
Parsing strategy: delegated to pyhocon (https://github.com/chimpler/pyhocon),
14+
the reference Python HOCON implementation. This avoids hand-rolled scanner
15+
pitfalls (key = { ... } prefix loss, triple-strings, substitutions, includes,
16+
+= operator, block comments). pyhocon returns a fully-merged ConfigTree where
17+
dotted-form keys are expanded into nested objects — i.e. the same canonical
18+
key set Typesafe Config / ConfigBeanFactory will see at runtime.
19+
20+
Array handling: keys inside object-elements of arrays are also user-defined
21+
config keys (e.g. each entry in `rate.limiter.rpc = [{ component=..., ... }]`
22+
is parsed by RateLimiterConfig). The walker recurses into list elements and
23+
treats the array step as a synthetic `[]` segment that contributes to depth
24+
but is not itself validated as a name. Element keys are deduplicated across
25+
list entries because well-formed arrays use homogeneous object shapes.
26+
27+
Debug mode: pass `--debug` to print every parsed key with its depth, in
28+
walk order (which mirrors the file top-to-bottom). Use this to eyeball the
29+
parser's view against reference.conf.
30+
31+
Exit code: 0 if clean, 1 if any violation remains after allowlist filtering,
32+
2 on environment errors (missing pyhocon, file not found, parse failure).
33+
34+
CI integration: invoked by the `Validate reference.conf key names and depth`
35+
step of the `checkstyle` job in `.github/workflows/pr-check.yml`. The non-zero
36+
exit on violations is what makes that step fail — there is intentionally NO
37+
extra `exit 1` in the workflow shell wrapper. A single GHA `::error` workflow
38+
command is also emitted unconditionally (not gated on the GITHUB_ACTIONS env
39+
var) so local runs produce the same output as CI; the leading `::` is
40+
harmless noise locally.
41+
"""
42+
import re
43+
import sys
44+
from pathlib import Path
45+
46+
try:
47+
from pyhocon import ConfigFactory, ConfigTree
48+
except ImportError:
49+
print(
50+
"error: pyhocon is required. Install with `pip install pyhocon`.",
51+
file=sys.stderr,
52+
)
53+
sys.exit(2)
54+
55+
# Set at the current max depth of reference.conf (5). No buffer: a mature
56+
# project should not allow silent drift, so any new key going deeper must
57+
# bump MAX_DEPTH via an explicit, reviewed change (deeper trees hurt
58+
# readability and complicate ConfigBeanFactory mapping).
59+
MAX_DEPTH = 5
60+
KEY_REGEX = re.compile(r'^[a-z][a-zA-Z0-9]*$')
61+
# Legacy keys grandfathered to keep user `config.conf` files compatible.
62+
# Do NOT extend this list for new keys — every new key must be lowerCamelCase.
63+
# A future rename + deprecation cycle can shrink this set back to empty.
64+
ALLOWLIST = {
65+
"node.http.PBFTEnable",
66+
"node.http.PBFTPort",
67+
"node.rpc.PBFTEnable",
68+
"node.rpc.PBFTPort",
69+
}
70+
71+
72+
def walk(node, path, depth):
73+
"""Yield (full_path, depth, is_leaf) for every reachable user-defined key.
74+
75+
- ConfigTree key adds one depth level and contributes a name segment.
76+
- list step adds one synthetic level rendered as `[]`. Element-internal
77+
keys are walked once per unique sub-path (homogeneous object arrays
78+
otherwise yield each field N times).
79+
- Scalars / null / list-of-scalars produce no further keys.
80+
81+
`depth` includes the array `[]` steps. `is_leaf` is True when the value
82+
at this path is a scalar/list/null — i.e. not another ConfigTree — so
83+
callers can filter leaves vs namespace intermediates.
84+
"""
85+
if isinstance(node, ConfigTree):
86+
for k, v in node.items():
87+
new_path = f"{path}.{k}" if path else k
88+
new_depth = depth + 1
89+
is_leaf = not isinstance(v, ConfigTree)
90+
yield new_path, new_depth, is_leaf
91+
yield from walk(v, new_path, new_depth)
92+
elif isinstance(node, list):
93+
array_path = f"{path}[]"
94+
array_depth = depth + 1
95+
seen = set()
96+
for elem in node:
97+
# Object element: walk its keys. Nested list element (HOCON allows
98+
# list-of-list, e.g. `a = [[{x=1}]]`): recurse so each inner [] step
99+
# also contributes to depth. Scalar elements have no sub-keys.
100+
if isinstance(elem, (ConfigTree, list)):
101+
for sub_path, sub_depth, sub_leaf in walk(elem, array_path, array_depth):
102+
if sub_path in seen:
103+
continue
104+
seen.add(sub_path)
105+
yield sub_path, sub_depth, sub_leaf
106+
107+
108+
def main(argv):
109+
debug = False
110+
args = list(argv[1:])
111+
if args and args[0] == "--debug":
112+
debug = True
113+
args = args[1:]
114+
if len(args) != 1:
115+
print(f"usage: {argv[0]} [--debug] <path/to/reference.conf>", file=sys.stderr)
116+
return 2
117+
path = Path(args[0])
118+
if not path.is_file():
119+
print(f"error: file not found: {path}", file=sys.stderr)
120+
return 2
121+
122+
try:
123+
tree = ConfigFactory.parse_file(str(path))
124+
except Exception as e:
125+
print(f"error: failed to parse {path}: {e}", file=sys.stderr)
126+
# Mirror the violation path: emit a single GHA annotation so the
127+
# parse failure surfaces in the PR check summary, not just the log.
128+
print(f"::error file={path},title=reference.conf::failed to parse: {e}")
129+
return 2
130+
131+
keys = list(walk(tree, "", 0))
132+
133+
if debug:
134+
# Keys are yielded in pyhocon insertion order, which mirrors the
135+
# source file top-to-bottom. Eyeball this against reference.conf to
136+
# confirm coverage; the depth column makes the array `[]` steps
137+
# explicit so MAX_DEPTH math is verifiable by inspection. Trailing
138+
# `/` marks namespace intermediates (have children); bare names are
139+
# leaves — `grep -v '/$'` filters to just leaves.
140+
leaf_count = sum(1 for _, _, lf in keys if lf)
141+
print(
142+
f"DEBUG: {len(keys)} parsed keys "
143+
f"({leaf_count} leaves + {len(keys) - leaf_count} intermediates), "
144+
f"walk order:"
145+
)
146+
for full_path, depth, is_leaf in keys:
147+
label = full_path if is_leaf else full_path + "/"
148+
print(f" d={depth} {label}")
149+
print()
150+
151+
format_violations = []
152+
depth_violations = []
153+
154+
# Only check leaves: pyhocon expands a dotted-form declaration like
155+
# `a.b.c = X` into intermediate ConfigTree nodes for `a` and `a.b`. A
156+
# single user-written bad key would otherwise be reported once per
157+
# intermediate AND once as the leaf, multiplying noise. The leaf path
158+
# carries every segment, so checking just leaves covers all segments.
159+
for full_path, depth, is_leaf in keys:
160+
if not is_leaf:
161+
continue
162+
if full_path not in ALLOWLIST:
163+
for seg in full_path.split('.'):
164+
# Strip any number of trailing `[]` markers — nested arrays
165+
# produce segments like `a[][]`.
166+
while seg.endswith('[]'):
167+
seg = seg[:-2]
168+
if seg and not KEY_REGEX.match(seg):
169+
format_violations.append((full_path, seg))
170+
break
171+
172+
if depth > MAX_DEPTH:
173+
depth_violations.append((full_path, depth))
174+
175+
format_violations.sort()
176+
depth_violations.sort()
177+
178+
if format_violations or depth_violations:
179+
lines_out = []
180+
if format_violations:
181+
lines_out.append(
182+
f"Format violations ({len(format_violations)}) — "
183+
f"each segment must match {KEY_REGEX.pattern}:"
184+
)
185+
for full_path, seg in format_violations:
186+
lines_out.append(f" format: {full_path} (segment: '{seg}')")
187+
if depth_violations:
188+
if lines_out:
189+
lines_out.append("")
190+
lines_out.append(
191+
f"Depth violations ({len(depth_violations)}) — max depth is {MAX_DEPTH} "
192+
f"(each `[]` array step counts as one level):"
193+
)
194+
for full_path, depth in depth_violations:
195+
lines_out.append(
196+
f" depth: {full_path} (depth={depth}, max={MAX_DEPTH})"
197+
)
198+
print("\n".join(lines_out))
199+
print()
200+
201+
# Emit ONE consolidated GHA workflow annotation. All offending entries
202+
# are packed into the annotation body via %0A (GHA's newline escape)
203+
# so the entries are visible in the annotation summary, not just in
204+
# the job log.
205+
entries = []
206+
for full_path, seg in format_violations:
207+
entries.append(f"format: {full_path} (segment '{seg}')")
208+
for full_path, depth in depth_violations:
209+
entries.append(f"depth: {full_path} (depth={depth}, max={MAX_DEPTH})")
210+
body = (
211+
f"reference.conf has {len(format_violations)} format + "
212+
f"{len(depth_violations)} depth violation(s):%0A"
213+
+ "%0A".join(entries)
214+
)
215+
print(f"::error file={path},title=reference.conf::{body}")
216+
print(
217+
f"FAIL: {len(format_violations)} format + {len(depth_violations)} depth "
218+
f"violation(s) in {path}",
219+
file=sys.stderr,
220+
)
221+
return 1
222+
223+
print(f"OK: {path}{len(keys)} keys, all lowerCamelCase, depth <= {MAX_DEPTH}")
224+
return 0
225+
226+
227+
if __name__ == "__main__":
228+
sys.exit(main(sys.argv))

.github/workflows/pr-check.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,20 @@ jobs:
103103
steps:
104104
- uses: actions/checkout@v5
105105

106+
- name: Set up Python
107+
uses: actions/setup-python@v5
108+
with:
109+
python-version: '3.11'
110+
111+
- name: Install pyhocon
112+
run: pip install --quiet pyhocon
113+
114+
- name: Validate reference.conf key names and depth
115+
shell: bash
116+
run: |
117+
python3 .github/scripts/check_reference_conf.py \
118+
common/src/main/resources/reference.conf
119+
106120
- name: Set up JDK 17
107121
uses: actions/setup-java@v5
108122
with:

0 commit comments

Comments
 (0)