Skip to content

Commit 7015551

Browse files
authored
Merge pull request #63 from SimonThalvorsen/main
ENT-13829: Errors on missing value and mutually exclusive types in vars promises
2 parents 367eed6 + d8854a5 commit 7015551

5 files changed

Lines changed: 165 additions & 54 deletions

File tree

.github/workflows/update-syntax-description.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,10 @@ jobs:
5151
- name: Update contents of syntax-description
5252
run: |
5353
if ! cmp -s new.json ./src/cfengine_cli/syntax-description.json; then
54-
cat new.json > ./src/cfengine_cli/syntax-description.json
55-
echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
56-
rm new.json
57-
fi
54+
cat new.json > ./src/cfengine_cli/syntax-description.json
55+
echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
56+
rm new.json
57+
fi
5858
- name: Create Pull Request
5959
if: env.CHANGES_DETECTED == 'true'
6060
uses: cfengine/create-pull-request@v6

src/cfengine_cli/lint.py

Lines changed: 103 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -42,49 +42,54 @@
4242
LINT_EXTENSIONS = (".cf", ".json")
4343

4444

45-
def _load_syntax_description(path: str | None = None) -> dict:
46-
"""Load and return the parsed syntax-description.json file."""
47-
if path is None:
48-
path = os.path.join(os.path.dirname(__file__), "syntax-description.json")
49-
with open(path, "r") as f:
50-
return json.load(f)
51-
52-
53-
def _derive_syntax_sets(data: dict) -> tuple:
54-
"""Derive the four sets used for linting from a loaded syntax-description dict.
55-
56-
Returns: (ALLOWED_BUNDLE_TYPES, BUILTIN_PROMISE_TYPES, BUILTIN_FUNCTIONS, DEPRECATED_PROMISE_TYPES)
57-
"""
58-
builtin_body_types = set(data.get("bodyTypes", {}).keys())
59-
60-
allowed_bundle_types = data.get("bundleTypes", {}).keys()
61-
62-
builtin_promise_types = set(data.get("promiseTypes", {}).keys())
45+
@dataclass
46+
class SyntaxData:
47+
BUILTIN_BODY_TYPES = {}
48+
BUILTIN_BUNDLE_TYPES = {}
49+
BUILTIN_PROMISE_TYPES = {}
50+
BUILTIN_FUNCTIONS = {}
51+
52+
def __init__(self):
53+
self._data_dict = self._load_syntax_description()
54+
self._derive_syntax_dicts(self._data_dict)
55+
56+
assert self.BUILTIN_BODY_TYPES
57+
assert self.BUILTIN_BUNDLE_TYPES
58+
assert self.BUILTIN_PROMISE_TYPES
59+
assert self.BUILTIN_FUNCTIONS
60+
61+
def _load_syntax_description(self, path: str | None = None) -> dict:
62+
"""Load and return the parsed syntax-description.json file."""
63+
if path is None:
64+
path = os.path.join(os.path.dirname(__file__), "syntax-description.json")
65+
with open(path, "r") as f:
66+
return json.load(f)
67+
68+
def _derive_syntax_dicts(self, data: dict):
69+
"""Derive the five dictionaries used for linting from a loaded syntax-description json-file.
70+
sets the (BUILTIN_BODY_TYPES, BUILTIN_BUNDLE_TYPES, BUILTIN_PROMISE_TYPES, BUILTIN_FUNCTIONS, DEPRECATED_PROMISE_TYPES) dicts
71+
"""
72+
builtin_body_types = data.get("bodyTypes", {})
6373

64-
builtin_functions = set(data.get("functions", {}).keys())
74+
builtin_bundle_types = data.get("bundleTypes", {})
6575

66-
deprecated_promise_types = {
67-
"defaults",
68-
"guest_environments",
69-
} # Has to be hardcoded, not tagged in syntax-description.json
76+
builtin_promise_types = data.get("promiseTypes", {})
7077

71-
return (
72-
builtin_body_types,
73-
allowed_bundle_types,
74-
builtin_promise_types,
75-
builtin_functions,
76-
deprecated_promise_types,
77-
)
78+
builtin_functions = data.get("functions", {})
7879

80+
deprecated_promise_types = {
81+
promise: builtin_promise_types.get(promise, {})
82+
for promise in {
83+
"defaults",
84+
"guest_environments",
85+
} # Has to be hardcoded, not tagged in syntax-description.json
86+
}
7987

80-
_SYNTAX_DATA = _load_syntax_description()
81-
(
82-
_,
83-
ALLOWED_BUNDLE_TYPES,
84-
BUILTIN_PROMISE_TYPES,
85-
BUILTIN_FUNCTIONS,
86-
DEPRECATED_PROMISE_TYPES,
87-
) = _derive_syntax_sets(_SYNTAX_DATA)
88+
self.BUILTIN_BODY_TYPES = builtin_body_types
89+
self.BUILTIN_BUNDLE_TYPES = builtin_bundle_types
90+
self.BUILTIN_PROMISE_TYPES = builtin_promise_types
91+
self.BUILTIN_FUNCTIONS = builtin_functions
92+
self.DEPRECATED_PROMISE_TYPES = deprecated_promise_types
8893

8994

9095
def _qualify(name: str, namespace: str) -> str:
@@ -487,7 +492,9 @@ def _discover(policy_file: PolicyFile, state: State) -> int:
487492
return 0
488493

489494

490-
def _lint_node(node: Node, policy_file: PolicyFile, state: State) -> int:
495+
def _lint_node(
496+
node: Node, policy_file: PolicyFile, state: State, syntax_data: SyntaxData
497+
) -> int:
491498
"""Checks we run on each node in the syntax tree,
492499
utilizes state for checks which require context."""
493500

@@ -503,15 +510,15 @@ def _lint_node(node: Node, policy_file: PolicyFile, state: State) -> int:
503510
if node.type == "promise_guard":
504511
assert _text(node) and len(_text(node)) > 1 and _text(node)[-1] == ":"
505512
promise_type = _text(node)[0:-1]
506-
if promise_type in DEPRECATED_PROMISE_TYPES:
513+
if promise_type in syntax_data.DEPRECATED_PROMISE_TYPES:
507514
_highlight_range(node, lines)
508515
print(
509516
f"Deprecation: Promise type '{promise_type}' is deprecated {location}"
510517
)
511518
return 1
512519
if (
513520
state.strict
514-
and promise_type not in BUILTIN_PROMISE_TYPES
521+
and promise_type not in syntax_data.BUILTIN_PROMISE_TYPES
515522
and promise_type not in state.custom_promise_types
516523
):
517524
_highlight_range(node, lines)
@@ -525,21 +532,60 @@ def _lint_node(node: Node, policy_file: PolicyFile, state: State) -> int:
525532
_highlight_range(node, lines)
526533
print(f"Convention: Promise type should be lowercase {location}")
527534
return 1
528-
if node.type == "bundle_block_type" and _text(node) not in ALLOWED_BUNDLE_TYPES:
535+
if (
536+
node.type == "bundle_block_type"
537+
and _text(node) not in syntax_data.BUILTIN_BUNDLE_TYPES
538+
):
529539
_highlight_range(node, lines)
530540
print(
531-
f"Error: Bundle type must be one of ({', '.join(ALLOWED_BUNDLE_TYPES)}), not '{_text(node)}' {location}"
541+
f"Error: Bundle type must be one of ({', '.join(syntax_data.BUILTIN_BUNDLE_TYPES)}), not '{_text(node)}' {location}"
532542
)
533543
return 1
534544
if state.strict and (
535545
node.type in ("bundle_block_name", "body_block_name")
536-
and _text(node) in BUILTIN_FUNCTIONS
546+
and _text(node) in syntax_data.BUILTIN_FUNCTIONS
537547
):
538548
_highlight_range(node, lines)
539549
print(
540550
f"Error: {'Bundle' if 'bundle' in node.type else 'Body'} '{_text(node)}' conflicts with built-in function with the same name {location}"
541551
)
542552
return 1
553+
if state.promise_type == "vars" and node.type == "promise":
554+
attribute_nodes = [x for x in node.children if x.type == "attribute"]
555+
if not attribute_nodes:
556+
_highlight_range(node, lines)
557+
print(
558+
f"Error: Missing attribute value for promiser "
559+
f"{_text(node)[:-1]} inside vars-promise type {location}"
560+
)
561+
return 1
562+
563+
mutually_excl_vars_attrs = {
564+
"data",
565+
"ilist",
566+
"int",
567+
"real",
568+
"rlist",
569+
"slist",
570+
"string",
571+
}
572+
573+
promise_type_attrs = {
574+
_text(child): attr_node
575+
for attr_node in attribute_nodes
576+
for child in attr_node.children
577+
if child.type == "attribute_name"
578+
and _text(child) in mutually_excl_vars_attrs
579+
}
580+
581+
if len(promise_type_attrs) > 1:
582+
for n in promise_type_attrs:
583+
_highlight_range(promise_type_attrs[n], lines)
584+
print(
585+
f"Error: Mutually exclusive attribute values {tuple(promise_type_attrs)} for a single promiser"
586+
f" inside vars-promise {location}"
587+
)
588+
return 1
543589
if node.type == "calling_identifier":
544590
name = _text(node)
545591
qualified_name = _qualify(name, state.namespace)
@@ -556,15 +602,15 @@ def _lint_node(node: Node, policy_file: PolicyFile, state: State) -> int:
556602
if state.strict and (
557603
qualified_name not in state.bundles
558604
and qualified_name not in state.bodies
559-
and name not in BUILTIN_FUNCTIONS
605+
and name not in syntax_data.BUILTIN_FUNCTIONS
560606
):
561607
_highlight_range(node, lines)
562608
print(
563609
f"Error: Call to unknown function / bundle / body '{name}' {location}"
564610
)
565611
return 1
566612
if (
567-
name not in BUILTIN_FUNCTIONS
613+
name not in syntax_data.BUILTIN_FUNCTIONS
568614
and state.promise_type == "vars"
569615
and state.attribute_name not in ("action", "classes")
570616
):
@@ -605,14 +651,14 @@ def _pass_fail_state(state: State, errors: int) -> str:
605651
return _pass_fail_filename(pretty_filename, errors)
606652

607653

608-
def _lint(policy_file: PolicyFile, state: State) -> int:
654+
def _lint(policy_file: PolicyFile, state: State, syntax_data: SyntaxData) -> int:
609655
"""Run linting rules (checks) on nodes in a policy file syntax tree."""
610656
assert state.mode == Mode.LINT
611657
errors = 0
612658
state.start_file(policy_file)
613659
for node in policy_file.nodes:
614660
state.navigate(node)
615-
errors += _lint_node(node, policy_file, state)
661+
errors += _lint_node(node, policy_file, state, syntax_data)
616662
message = _pass_fail_state(state, errors)
617663
state.end_file()
618664
if state.prefix:
@@ -738,7 +784,11 @@ def _args_to_filenames(args: list[str]) -> list[str]:
738784

739785

740786
def _lint_main(
741-
args: list[str], strict: bool, state=None, snippet: Snippet | None = None
787+
args: list[str],
788+
strict: bool,
789+
state=None,
790+
snippet: Snippet | None = None,
791+
syntax_data=None,
742792
) -> int:
743793
"""This is the main function used for linting, it does all the steps on all
744794
the arguments (files / folders).
@@ -765,6 +815,9 @@ def _lint_main(
765815
state.strict = strict
766816
state.mode = Mode.SYNTAX
767817

818+
if syntax_data is None:
819+
syntax_data = SyntaxData()
820+
768821
filenames = _args_to_filenames(args)
769822

770823
if snippet:
@@ -799,7 +852,7 @@ def _lint_main(
799852
state.mode = Mode.LINT
800853

801854
for policy_file in policy_files:
802-
errors += _lint(policy_file, state)
855+
errors += _lint(policy_file, state, syntax_data)
803856

804857
return errors
805858

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
bundle agent foo
2+
{
3+
vars:
4+
"policy"
5+
policy => "free",
6+
real => "11.11";
7+
8+
"noerr"
9+
slist => {};
10+
int => "string";
11+
12+
"noerr"
13+
slist => {}
14+
int => "string";
15+
16+
"comment"
17+
slist => {},
18+
comment => "string";
19+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
2+
"error"
3+
slist => {},
4+
^---------^
5+
6+
slist => {},
7+
int => "10",
8+
^---------^
9+
10+
policy => "free",
11+
real => "0.5";
12+
^-----------^
13+
Error: Mutually exclusive attribute values ('slist', 'int', 'real') for a single promiser inside vars-promise at tests/lint/011_mutually_exclusive_types_vars.x.cf:4:5
14+
15+
16+
"missing-error";
17+
^--------------^
18+
Error: Missing attribute value for promiser "missing-error" inside vars-promise type at tests/lint/011_mutually_exclusive_types_vars.x.cf:18:5
19+
FAIL: tests/lint/011_mutually_exclusive_types_vars.x.cf (2 errors)
20+
Failure, 2 errors in total.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
bundle agent foo
2+
{
3+
vars:
4+
"error"
5+
slist => {},
6+
int => "10",
7+
policy => "free",
8+
real => "0.5";
9+
10+
"noerr"
11+
slist => {};
12+
int => "string";
13+
14+
"noerr"
15+
slist => {}
16+
int => "string";
17+
18+
"missing-error";
19+
}

0 commit comments

Comments
 (0)