From 4b9f86b5fc6aedc6bc997b1c2f889271eb47c51f Mon Sep 17 00:00:00 2001 From: Ole Herman Schumacher Elgesem Date: Fri, 1 May 2026 02:06:19 +0200 Subject: [PATCH 01/16] cfengine lint: Stop looking at functions when attr name implies it's not a function Co-authored-by: Claude Opus 4.7 (1M context) Signed-off-by: Ole Herman Schumacher Elgesem --- src/cfengine_cli/lint.py | 21 +++++++++++++++------ tests/lint/018_implies_body.expected.txt | 14 ++++++++++++-- tests/lint/018_implies_body.x.cf | 5 +++++ 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/src/cfengine_cli/lint.py b/src/cfengine_cli/lint.py index 2c36814..c1fffa9 100644 --- a/src/cfengine_cli/lint.py +++ b/src/cfengine_cli/lint.py @@ -659,11 +659,16 @@ def _lint_node( f"Error: Call to bundle '{name}' inside custom promise: '{state.promise_type}' {location}" ) return 1 - if state.strict and name not in syntax_data.BUILTIN_FUNCTIONS: - allowed_in_bundles = state.attribute_name not in IMPLIES_BODY - allowed_in_bodies = state.attribute_name not in IMPLIES_BUNDLE - found = (allowed_in_bundles and qualified_name in state.bundles) or ( - allowed_in_bodies and qualified_name in state.bodies + if state.strict: + implies_bundle = state.attribute_name in IMPLIES_BUNDLE + implies_body = state.attribute_name in IMPLIES_BODY + allowed_in_bundles = not implies_body + allowed_in_bodies = not implies_bundle + allowed_as_function = not implies_bundle and not implies_body + found = ( + (allowed_in_bundles and qualified_name in state.bundles) + or (allowed_in_bodies and qualified_name in state.bodies) + or (allowed_as_function and name in syntax_data.BUILTIN_FUNCTIONS) ) if not found: _highlight_range(node, lines) @@ -725,7 +730,11 @@ def _lint_node( filter(",".__ne__, iter(_text(x) for x in args if x.type != "comment")) ) - if call in syntax_data.BUILTIN_FUNCTIONS: + if ( + call in syntax_data.BUILTIN_FUNCTIONS + and state.attribute_name not in IMPLIES_BUNDLE + and state.attribute_name not in IMPLIES_BODY + ): func = syntax_data.BUILTIN_FUNCTIONS.get(call, {}) variadic = func.get("variadic", True) # variadic meaning variable amount of arguments allowed diff --git a/tests/lint/018_implies_body.expected.txt b/tests/lint/018_implies_body.expected.txt index 4b31700..352644e 100644 --- a/tests/lint/018_implies_body.expected.txt +++ b/tests/lint/018_implies_body.expected.txt @@ -13,5 +13,15 @@ Error: Call to unknown function / bundle / body 'unknown_name' at tests/lint/018 copy_from => mycopy("/src"); ^------------^ Error: Expected 2 arguments, received 1 for body 'mycopy' at tests/lint/018_implies_body.x.cf:21:20 -FAIL: tests/lint/018_implies_body.x.cf (3 errors) -Failure, 3 errors in total. + + "/tmp/test4" + copy_from => readfile("/etc/file", "100"); + ^------^ +Error: Call to unknown function / bundle / body 'readfile' at tests/lint/018_implies_body.x.cf:23:20 + + "test5" + usebundle => readfile("/etc/file", "100"); + ^------^ +Error: Call to unknown function / bundle / body 'readfile' at tests/lint/018_implies_body.x.cf:26:20 +FAIL: tests/lint/018_implies_body.x.cf (5 errors) +Failure, 5 errors in total. diff --git a/tests/lint/018_implies_body.x.cf b/tests/lint/018_implies_body.x.cf index 8d2ab2c..22ceac2 100644 --- a/tests/lint/018_implies_body.x.cf +++ b/tests/lint/018_implies_body.x.cf @@ -19,4 +19,9 @@ bundle agent main copy_from => unknown_name("oops"); "/tmp/test3" copy_from => mycopy("/src"); + "/tmp/test4" + copy_from => readfile("/etc/file", "100"); + methods: + "test5" + usebundle => readfile("/etc/file", "100"); } From 8f3ae90bb7a79685120ce92b91fab5d853e23124 Mon Sep 17 00:00:00 2001 From: Ole Herman Schumacher Elgesem Date: Fri, 1 May 2026 02:12:23 +0200 Subject: [PATCH 02/16] Added detailed error messages for unknown body / bundle / function Co-authored-by: Claude Opus 4.7 (1M context) Signed-off-by: Ole Herman Schumacher Elgesem --- src/cfengine_cli/lint.py | 46 +++++++++++++++++------- tests/lint/008_namespace.expected.txt | 2 +- tests/lint/018_implies_body.expected.txt | 22 ++++++++---- tests/lint/018_implies_body.x.cf | 4 +++ 4 files changed, 55 insertions(+), 19 deletions(-) diff --git a/src/cfengine_cli/lint.py b/src/cfengine_cli/lint.py index c1fffa9..c0c1148 100644 --- a/src/cfengine_cli/lint.py +++ b/src/cfengine_cli/lint.py @@ -662,19 +662,41 @@ def _lint_node( if state.strict: implies_bundle = state.attribute_name in IMPLIES_BUNDLE implies_body = state.attribute_name in IMPLIES_BODY - allowed_in_bundles = not implies_body - allowed_in_bodies = not implies_bundle - allowed_as_function = not implies_bundle and not implies_body - found = ( - (allowed_in_bundles and qualified_name in state.bundles) - or (allowed_in_bodies and qualified_name in state.bodies) - or (allowed_as_function and name in syntax_data.BUILTIN_FUNCTIONS) - ) - if not found: + is_bundle = qualified_name in state.bundles + is_body = qualified_name in state.bodies + is_function = name in syntax_data.BUILTIN_FUNCTIONS + + error = None + if implies_bundle and not is_bundle: + if is_body: + error = ( + f"Error: Expected a bundle but '{name}' is a body {location}" + ) + elif is_function: + error = f"Error: Expected a bundle but '{name}' is a built-in function {location}" + else: + error = f"Error: Call to unknown bundle '{name}' {location}" + elif implies_body and not is_body: + if is_bundle: + error = ( + f"Error: Expected a body but '{name}' is a bundle {location}" + ) + elif is_function: + error = f"Error: Expected a body but '{name}' is a built-in function {location}" + else: + error = f"Error: Call to unknown body '{name}' {location}" + elif ( + not implies_bundle + and not implies_body + and not is_bundle + and not is_body + and not is_function + ): + error = f"Error: Call to unknown function / bundle / body '{name}' {location}" + + if error: _highlight_range(node, lines) - print( - f"Error: Call to unknown function / bundle / body '{name}' {location}" - ) + print(error) return 1 if ( name not in syntax_data.BUILTIN_FUNCTIONS diff --git a/tests/lint/008_namespace.expected.txt b/tests/lint/008_namespace.expected.txt index e1f936d..58634ad 100644 --- a/tests/lint/008_namespace.expected.txt +++ b/tests/lint/008_namespace.expected.txt @@ -2,6 +2,6 @@ methods: "x" usebundle => default:target("arg"); ^------------^ -Error: Call to unknown function / bundle / body 'default:target' at tests/lint/008_namespace.x.cf:15:22 +Error: Call to unknown bundle 'default:target' at tests/lint/008_namespace.x.cf:15:22 FAIL: tests/lint/008_namespace.x.cf (1 error) Failure, 1 error in total. diff --git a/tests/lint/018_implies_body.expected.txt b/tests/lint/018_implies_body.expected.txt index 352644e..169e792 100644 --- a/tests/lint/018_implies_body.expected.txt +++ b/tests/lint/018_implies_body.expected.txt @@ -2,12 +2,12 @@ "/tmp/test1" copy_from => helper("oops"); ^----^ -Error: Call to unknown function / bundle / body 'helper' at tests/lint/018_implies_body.x.cf:17:20 +Error: Expected a body but 'helper' is a bundle at tests/lint/018_implies_body.x.cf:17:20 "/tmp/test2" copy_from => unknown_name("oops"); ^----------^ -Error: Call to unknown function / bundle / body 'unknown_name' at tests/lint/018_implies_body.x.cf:19:20 +Error: Call to unknown body 'unknown_name' at tests/lint/018_implies_body.x.cf:19:20 "/tmp/test3" copy_from => mycopy("/src"); @@ -17,11 +17,21 @@ Error: Expected 2 arguments, received 1 for body 'mycopy' at tests/lint/018_impl "/tmp/test4" copy_from => readfile("/etc/file", "100"); ^------^ -Error: Call to unknown function / bundle / body 'readfile' at tests/lint/018_implies_body.x.cf:23:20 +Error: Expected a body but 'readfile' is a built-in function at tests/lint/018_implies_body.x.cf:23:20 "test5" + usebundle => mycopy("/src", "host"); + ^----^ +Error: Expected a bundle but 'mycopy' is a body at tests/lint/018_implies_body.x.cf:26:20 + + "test6" usebundle => readfile("/etc/file", "100"); ^------^ -Error: Call to unknown function / bundle / body 'readfile' at tests/lint/018_implies_body.x.cf:26:20 -FAIL: tests/lint/018_implies_body.x.cf (5 errors) -Failure, 5 errors in total. +Error: Expected a bundle but 'readfile' is a built-in function at tests/lint/018_implies_body.x.cf:28:20 + + "test7" + usebundle => unknown_bundle("oops"); + ^------------^ +Error: Call to unknown bundle 'unknown_bundle' at tests/lint/018_implies_body.x.cf:30:20 +FAIL: tests/lint/018_implies_body.x.cf (7 errors) +Failure, 7 errors in total. diff --git a/tests/lint/018_implies_body.x.cf b/tests/lint/018_implies_body.x.cf index 22ceac2..f83ea7b 100644 --- a/tests/lint/018_implies_body.x.cf +++ b/tests/lint/018_implies_body.x.cf @@ -23,5 +23,9 @@ bundle agent main copy_from => readfile("/etc/file", "100"); methods: "test5" + usebundle => mycopy("/src", "host"); + "test6" usebundle => readfile("/etc/file", "100"); + "test7" + usebundle => unknown_bundle("oops"); } From 3fbcce5fc923fe6a5f7f9de74c2dd2fd1fd4a516 Mon Sep 17 00:00:00 2001 From: Ole Herman Schumacher Elgesem Date: Fri, 1 May 2026 02:18:49 +0200 Subject: [PATCH 03/16] lint.py: Fixed internal issue causing definitions to be discovered and saved twice Co-authored-by: Claude Opus 4.7 (1M context) Signed-off-by: Ole Herman Schumacher Elgesem --- src/cfengine_cli/lint.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/cfengine_cli/lint.py b/src/cfengine_cli/lint.py index c0c1148..bbe1010 100644 --- a/src/cfengine_cli/lint.py +++ b/src/cfengine_cli/lint.py @@ -496,7 +496,6 @@ def _check_syntax(policy_file: PolicyFile, state: State) -> int: state.start_file(policy_file) for node in policy_file.nodes: state.navigate(node) - _discover_node(node, state) if node.type != "ERROR": continue line = node.range.start_point[0] + 1 From 8b1031f008c6a9212ba4c5d19c0a14bd64738248 Mon Sep 17 00:00:00 2001 From: Ole Herman Schumacher Elgesem Date: Fri, 1 May 2026 02:25:23 +0200 Subject: [PATCH 04/16] cfengine lint: Added hints about where something is defined Co-authored-by: Claude Opus 4.7 (1M context) Signed-off-by: Ole Herman Schumacher Elgesem --- src/cfengine_cli/lint.py | 11 +++++++++++ tests/lint/014_num_args_body.expected.txt | 3 +++ tests/lint/014_num_args_bundle.expected.txt | 3 +++ tests/lint/016_macro_multi_def_bundle.expected.txt | 2 ++ tests/lint/018_implies_body.expected.txt | 1 + 5 files changed, 20 insertions(+) diff --git a/src/cfengine_cli/lint.py b/src/cfengine_cli/lint.py index bbe1010..bddd680 100644 --- a/src/cfengine_cli/lint.py +++ b/src/cfengine_cli/lint.py @@ -329,6 +329,7 @@ def _add_definition(self, name: str, node: Node, definitions: dict) -> None: definition = { "filename": self.policy_file.filename, "line": node.range.start_point[0] + 1, + "column": node.range.start_point[1] + 1, "parameters": parameters, } if self.macro: @@ -803,6 +804,7 @@ def _lint_node( print( f"Error: Expected {expected} arguments, received {len(args)} for bundle '{call}' {location}" ) + _print_definition_hints("bundle", call, definitions) return 1 if ( qualified_name in state.bodies @@ -817,6 +819,7 @@ def _lint_node( print( f"Error: Expected {expected} arguments, received {len(args)} for body '{call}' {location}" ) + _print_definition_hints("body", call, definitions) return 1 if node.type == "half_promise": prev_sib = node.prev_named_sibling @@ -1109,6 +1112,14 @@ def _text(node: Node) -> str: return node.text.decode() +def _print_definition_hints(kind: str, name: str, definitions: list[dict]) -> None: + """Print a single 'Hint:' line, joining all definition locations with ' and '.""" + locations = " and ".join( + f"{d['filename']}:{d['line']}:{d['column']}" for d in definitions + ) + print(f"Hint: The {kind} '{name}' is defined at {locations}") + + def _walk_callback(node: Node, callback: Callable[[Node], int]) -> int: """Recursively walk a syntax tree, calling the callback on each node.""" assert node diff --git a/tests/lint/014_num_args_body.expected.txt b/tests/lint/014_num_args_body.expected.txt index 16ce7c7..98d5b5a 100644 --- a/tests/lint/014_num_args_body.expected.txt +++ b/tests/lint/014_num_args_body.expected.txt @@ -3,15 +3,18 @@ perms => mog("644"); ^--------^ Error: Expected 3 arguments, received 1 for body 'mog' at tests/lint/014_num_args_body.x.cf:13:16 +Hint: The body 'mog' is defined at tests/lint/014_num_args_body.x.cf:1:12 create => "true", perms => mog("644", "root"); ^----------------^ Error: Expected 3 arguments, received 2 for body 'mog' at tests/lint/014_num_args_body.x.cf:16:16 +Hint: The body 'mog' is defined at tests/lint/014_num_args_body.x.cf:1:12 create => "true", perms => mog("644", "root", "root", "root"); ^--------------------------------^ Error: Expected 3 arguments, received 4 for body 'mog' at tests/lint/014_num_args_body.x.cf:22:16 +Hint: The body 'mog' is defined at tests/lint/014_num_args_body.x.cf:1:12 FAIL: tests/lint/014_num_args_body.x.cf (3 errors) Failure, 3 errors in total. diff --git a/tests/lint/014_num_args_bundle.expected.txt b/tests/lint/014_num_args_bundle.expected.txt index f9a37dd..878cb86 100644 --- a/tests/lint/014_num_args_bundle.expected.txt +++ b/tests/lint/014_num_args_bundle.expected.txt @@ -3,15 +3,18 @@ usebundle => test(); ^----^ Error: Expected 2 arguments, received 0 for bundle 'test' at tests/lint/014_num_args_bundle.x.cf:5:20 +Hint: The bundle 'test' is defined at tests/lint/014_num_args_bundle.x.cf:13:14 "test2" usebundle => test("a"); ^-------^ Error: Expected 2 arguments, received 1 for bundle 'test' at tests/lint/014_num_args_bundle.x.cf:7:20 +Hint: The bundle 'test' is defined at tests/lint/014_num_args_bundle.x.cf:13:14 "test4" usebundle => test("a", "b", "c"); ^-----------------^ Error: Expected 2 arguments, received 3 for bundle 'test' at tests/lint/014_num_args_bundle.x.cf:11:20 +Hint: The bundle 'test' is defined at tests/lint/014_num_args_bundle.x.cf:13:14 FAIL: tests/lint/014_num_args_bundle.x.cf (3 errors) Failure, 3 errors in total. diff --git a/tests/lint/016_macro_multi_def_bundle.expected.txt b/tests/lint/016_macro_multi_def_bundle.expected.txt index 23ae3f7..ac69174 100644 --- a/tests/lint/016_macro_multi_def_bundle.expected.txt +++ b/tests/lint/016_macro_multi_def_bundle.expected.txt @@ -3,10 +3,12 @@ usebundle => test(); ^----^ Error: Expected 1 or 2 arguments, received 0 for bundle 'test' at tests/lint/016_macro_multi_def_bundle.x.cf:20:20 +Hint: The bundle 'test' is defined at tests/lint/016_macro_multi_def_bundle.x.cf:2:14 and tests/lint/016_macro_multi_def_bundle.x.cf:8:14 "test2" usebundle => test("hello", "world", "!"); ^-------------------------^ Error: Expected 1 or 2 arguments, received 3 for bundle 'test' at tests/lint/016_macro_multi_def_bundle.x.cf:23:20 +Hint: The bundle 'test' is defined at tests/lint/016_macro_multi_def_bundle.x.cf:2:14 and tests/lint/016_macro_multi_def_bundle.x.cf:8:14 FAIL: tests/lint/016_macro_multi_def_bundle.x.cf (2 errors) Failure, 2 errors in total. diff --git a/tests/lint/018_implies_body.expected.txt b/tests/lint/018_implies_body.expected.txt index 169e792..9e42486 100644 --- a/tests/lint/018_implies_body.expected.txt +++ b/tests/lint/018_implies_body.expected.txt @@ -13,6 +13,7 @@ Error: Call to unknown body 'unknown_name' at tests/lint/018_implies_body.x.cf:1 copy_from => mycopy("/src"); ^------------^ Error: Expected 2 arguments, received 1 for body 'mycopy' at tests/lint/018_implies_body.x.cf:21:20 +Hint: The body 'mycopy' is defined at tests/lint/018_implies_body.x.cf:1:16 "/tmp/test4" copy_from => readfile("/etc/file", "100"); From 0fc27f758ab1cdfd6bd0c1e6356ffab06afd6abd Mon Sep 17 00:00:00 2001 From: Ole Herman Schumacher Elgesem Date: Fri, 1 May 2026 02:48:54 +0200 Subject: [PATCH 05/16] cfengine lint: Always look for built in functions inside nested calls Co-authored-by: Claude Opus 4.7 (1M context) Signed-off-by: Ole Herman Schumacher Elgesem --- src/cfengine_cli/lint.py | 62 +++++++++++++++++++----- tests/lint/019_nested_calls.cf | 22 +++++++++ tests/lint/019_nested_calls.expected.txt | 22 +++++++++ tests/lint/019_nested_calls.x.cf | 24 +++++++++ 4 files changed, 118 insertions(+), 12 deletions(-) create mode 100644 tests/lint/019_nested_calls.cf create mode 100644 tests/lint/019_nested_calls.expected.txt create mode 100644 tests/lint/019_nested_calls.x.cf diff --git a/src/cfengine_cli/lint.py b/src/cfengine_cli/lint.py index bddd680..9625ba9 100644 --- a/src/cfengine_cli/lint.py +++ b/src/cfengine_cli/lint.py @@ -220,6 +220,8 @@ class State: mode: Mode = Mode.NONE walking: bool = False strict: bool = True + inside_call: bool = False # True when nested inside another call's arguments + call_depth: int = 0 # tracks call nesting; inside_call is call_depth > 1 bundles = {} bodies = {} custom_promise_types = {} @@ -434,6 +436,19 @@ def navigate(self, node) -> None: # Attributes always end with ; in all 3 block types if node.type == ";": self.attribute_name = None + self.call_depth = 0 + self.inside_call = False + return + + # Track call nesting so we can recognize nested calls as built-in + # functions even when the attribute name implies bundle/body. + if node.type == "call": + self.call_depth += 1 + self.inside_call = self.call_depth > 1 + return + if node.type == ")" and node.parent and node.parent.type == "call": + self.call_depth -= 1 + self.inside_call = self.call_depth > 1 return # Clear things when ending a top level block: @@ -649,9 +664,28 @@ def _lint_node( if node.type == "calling_identifier": name = _text(node) qualified_name = _qualify(name, state.namespace) + is_bundle = qualified_name in state.bundles + is_body = qualified_name in state.bodies + is_function = name in syntax_data.BUILTIN_FUNCTIONS + + if state.inside_call: + # Nested calls must be built-in functions - the surrounding + # attribute's IMPLIES_BUNDLE/IMPLIES_BODY only applies to the + # outermost call. + if not is_function: + if is_bundle: + error = f"Error: Expected a built-in function but '{name}' is a bundle {location}" + elif is_body: + error = f"Error: Expected a built-in function but '{name}' is a body {location}" + else: + error = f"Error: Call to unknown function '{name}' {location}" + _highlight_range(node, lines) + print(error) + return 1 + return 0 if ( state.strict - and qualified_name in state.bundles + and is_bundle and state.promise_type in state.custom_promise_types ): _highlight_range(node, lines) @@ -662,9 +696,6 @@ def _lint_node( if state.strict: implies_bundle = state.attribute_name in IMPLIES_BUNDLE implies_body = state.attribute_name in IMPLIES_BODY - is_bundle = qualified_name in state.bundles - is_body = qualified_name in state.bodies - is_function = name in syntax_data.BUILTIN_FUNCTIONS error = None if implies_bundle and not is_bundle: @@ -699,7 +730,7 @@ def _lint_node( print(error) return 1 if ( - name not in syntax_data.BUILTIN_FUNCTIONS + not is_function and state.promise_type == "vars" and state.attribute_name not in ("action", "classes") ): @@ -711,7 +742,7 @@ def _lint_node( if ( state.promise_type == "vars" and state.attribute_name in ("action", "classes") - and qualified_name not in state.bodies + and not is_body ): _highlight_range(node, lines) print( @@ -752,10 +783,12 @@ def _lint_node( filter(",".__ne__, iter(_text(x) for x in args if x.type != "comment")) ) - if ( - call in syntax_data.BUILTIN_FUNCTIONS - and state.attribute_name not in IMPLIES_BUNDLE - and state.attribute_name not in IMPLIES_BODY + if call in syntax_data.BUILTIN_FUNCTIONS and ( + state.inside_call + or ( + state.attribute_name not in IMPLIES_BUNDLE + and state.attribute_name not in IMPLIES_BODY + ) ): func = syntax_data.BUILTIN_FUNCTIONS.get(call, {}) variadic = func.get("variadic", True) @@ -794,7 +827,11 @@ def _lint_node( return 1 qualified_name = _qualify(call, state.namespace) - if qualified_name in state.bundles and state.attribute_name not in IMPLIES_BODY: + if ( + not state.inside_call + and qualified_name in state.bundles + and state.attribute_name not in IMPLIES_BODY + ): definitions = state.bundles[qualified_name] valid_counts = {len(d.get("parameters", [])) for d in definitions} if len(args) not in valid_counts: @@ -807,7 +844,8 @@ def _lint_node( _print_definition_hints("bundle", call, definitions) return 1 if ( - qualified_name in state.bodies + not state.inside_call + and qualified_name in state.bodies and state.attribute_name not in IMPLIES_BUNDLE ): definitions = state.bodies[qualified_name] diff --git a/tests/lint/019_nested_calls.cf b/tests/lint/019_nested_calls.cf new file mode 100644 index 0000000..7f649b2 --- /dev/null +++ b/tests/lint/019_nested_calls.cf @@ -0,0 +1,22 @@ +body copy_from mycopy(from) +{ + source => "$(from)"; +} + +bundle agent helper(arg) +{ + reports: + "$(arg)"; +} + +bundle agent main +{ + files: + "/tmp/test" + copy_from => mycopy(readfile("/etc/source", "100")); + methods: + "test" + usebundle => helper(format("hello %s", "world")); + vars: + "x" string => format("nested: %s", getuid("root")); +} diff --git a/tests/lint/019_nested_calls.expected.txt b/tests/lint/019_nested_calls.expected.txt new file mode 100644 index 0000000..dbe712f --- /dev/null +++ b/tests/lint/019_nested_calls.expected.txt @@ -0,0 +1,22 @@ + + "test1" + usebundle => helper(helper("nested-bundle")); + ^----^ +Error: Expected a built-in function but 'helper' is a bundle at tests/lint/019_nested_calls.x.cf:16:27 + + "test2" + usebundle => helper(mycopy("nested-body")); + ^----^ +Error: Expected a built-in function but 'mycopy' is a body at tests/lint/019_nested_calls.x.cf:18:27 + + "test3" + usebundle => helper(unknown_name("oops")); + ^----------^ +Error: Call to unknown function 'unknown_name' at tests/lint/019_nested_calls.x.cf:20:27 + + "/tmp/test4" + copy_from => mycopy(unknown_name("oops")); + ^----------^ +Error: Call to unknown function 'unknown_name' at tests/lint/019_nested_calls.x.cf:23:27 +FAIL: tests/lint/019_nested_calls.x.cf (4 errors) +Failure, 4 errors in total. diff --git a/tests/lint/019_nested_calls.x.cf b/tests/lint/019_nested_calls.x.cf new file mode 100644 index 0000000..a64dcb4 --- /dev/null +++ b/tests/lint/019_nested_calls.x.cf @@ -0,0 +1,24 @@ +body copy_from mycopy(from) +{ + source => "$(from)"; +} + +bundle agent helper(arg) +{ + reports: + "$(arg)"; +} + +bundle agent main +{ + methods: + "test1" + usebundle => helper(helper("nested-bundle")); + "test2" + usebundle => helper(mycopy("nested-body")); + "test3" + usebundle => helper(unknown_name("oops")); + files: + "/tmp/test4" + copy_from => mycopy(unknown_name("oops")); +} From bb28eb8c28825931bc4c51dd5c72c87e77dd7438 Mon Sep 17 00:00:00 2001 From: Ole Herman Schumacher Elgesem Date: Fri, 1 May 2026 03:03:42 +0200 Subject: [PATCH 06/16] Started using exceptions for error handling in linting code Co-authored-by: Claude Opus 4.7 (1M context) Signed-off-by: Ole Herman Schumacher Elgesem --- src/cfengine_cli/lint.py | 187 ++++++++++++++++++++------------------- 1 file changed, 96 insertions(+), 91 deletions(-) diff --git a/src/cfengine_cli/lint.py b/src/cfengine_cli/lint.py index 9625ba9..86e88d2 100644 --- a/src/cfengine_cli/lint.py +++ b/src/cfengine_cli/lint.py @@ -573,63 +573,64 @@ def _discover(policy_file: PolicyFile, state: State) -> int: def _lint_node( node: Node, policy_file: PolicyFile, state: State, syntax_data: SyntaxData -) -> int: +) -> None: """Checks we run on each node in the syntax tree, - utilizes state for checks which require context.""" + utilizes state for checks which require context. + + Raises ValidationError when a check fails. The exception carries the node + to highlight, the error message, and any hint lines; the caller renders + them. + """ - lines = policy_file.lines line = node.range.start_point[0] + 1 column = node.range.start_point[1] + 1 location = state.get_location_extended(line, column) if node.type == "attribute_name" and _text(node) == "ifvarclass": - _highlight_range(node, lines) - print(f"Deprecation: Use 'if' instead of 'ifvarclass' {location}") - return 1 + raise ValidationError( + f"Deprecation: Use 'if' instead of 'ifvarclass' {location}", node + ) if node.type == "promise_guard": assert _text(node) and len(_text(node)) > 1 and _text(node)[-1] == ":" promise_type = _text(node)[0:-1] if promise_type in syntax_data.DEPRECATED_PROMISE_TYPES: - _highlight_range(node, lines) - print( - f"Deprecation: Promise type '{promise_type}' is deprecated {location}" + raise ValidationError( + f"Deprecation: Promise type '{promise_type}' is deprecated {location}", + node, ) - return 1 if ( state.strict and promise_type not in syntax_data.BUILTIN_PROMISE_TYPES and promise_type not in state.custom_promise_types ): - _highlight_range(node, lines) - print(f"Error: Undefined promise type '{promise_type}' {location}") - return 1 + raise ValidationError( + f"Error: Undefined promise type '{promise_type}' {location}", node + ) if node.type == "bundle_block_name" and _text(node) != _text(node).lower(): - _highlight_range(node, lines) - print(f"Convention: Bundle name should be lowercase {location}") - return 1 + raise ValidationError( + f"Convention: Bundle name should be lowercase {location}", node + ) if node.type == "promise_block_name" and _text(node) != _text(node).lower(): - _highlight_range(node, lines) - print(f"Convention: Promise type should be lowercase {location}") - return 1 + raise ValidationError( + f"Convention: Promise type should be lowercase {location}", node + ) if ( node.type == "bundle_block_type" and _text(node) not in syntax_data.BUILTIN_BUNDLE_TYPES ): - _highlight_range(node, lines) - print( - f"Error: Bundle type must be one of ({', '.join(syntax_data.BUILTIN_BUNDLE_TYPES)}), not '{_text(node)}' {location}" + raise ValidationError( + f"Error: Bundle type must be one of ({', '.join(syntax_data.BUILTIN_BUNDLE_TYPES)}), not '{_text(node)}' {location}", + node, ) - return 1 if state.strict and ( node.type in ("bundle_block_name", "body_block_name") and _text(node) in syntax_data.BUILTIN_FUNCTIONS and _text(node) not in KNOWN_FAULTY_FUNCTION_DEFS ): - _highlight_range(node, lines) - print( - f"Error: {'Bundle' if 'bundle' in node.type else 'Body'} '{_text(node)}' conflicts with built-in function with the same name {location}" + raise ValidationError( + f"Error: {'Bundle' if 'bundle' in node.type else 'Body'} '{_text(node)}' conflicts with built-in function with the same name {location}", + node, ) - return 1 if state.promise_type == "vars" and node.type == "promise": attribute_nodes = [x for x in node.children if x.type == "attribute"] # Attributes are children of a promise, and attribute names are children of attributes @@ -645,22 +646,20 @@ def _lint_node( if not value_nodes: # None of vars_types were found - _highlight_range(node, lines) - print( - f"Error: Missing value for vars promise {_text(node)[:-1]} {location}" + raise ValidationError( + f"Error: Missing value for vars promise {_text(node)[:-1]} {location}", + node, ) - return 1 if len(value_nodes) > 1: # Too many of vars_types was found # TODO: We could improve _highlight_range to highlight multiple nodes in a nice way - _highlight_range(value_nodes[-1], lines) nodes = ", ".join([_text(x) for x in value_nodes]) - print( + raise ValidationError( f"Error: Mutually exclusive attribute values ({nodes})" - f" for a single promiser inside vars-promise {location}" + f" for a single promiser inside vars-promise {location}", + value_nodes[-1], ) - return 1 if node.type == "calling_identifier": name = _text(node) qualified_name = _qualify(name, state.namespace) @@ -679,20 +678,17 @@ def _lint_node( error = f"Error: Expected a built-in function but '{name}' is a body {location}" else: error = f"Error: Call to unknown function '{name}' {location}" - _highlight_range(node, lines) - print(error) - return 1 - return 0 + raise ValidationError(error, node) + return if ( state.strict and is_bundle and state.promise_type in state.custom_promise_types ): - _highlight_range(node, lines) - print( - f"Error: Call to bundle '{name}' inside custom promise: '{state.promise_type}' {location}" + raise ValidationError( + f"Error: Call to bundle '{name}' inside custom promise: '{state.promise_type}' {location}", + node, ) - return 1 if state.strict: implies_bundle = state.attribute_name in IMPLIES_BUNDLE implies_body = state.attribute_name in IMPLIES_BODY @@ -726,58 +722,52 @@ def _lint_node( error = f"Error: Call to unknown function / bundle / body '{name}' {location}" if error: - _highlight_range(node, lines) - print(error) - return 1 + raise ValidationError(error, node) if ( not is_function and state.promise_type == "vars" and state.attribute_name not in ("action", "classes") ): - _highlight_range(node, lines) - print( - f"Error: Call to unknown function '{name}' inside 'vars'-promise {location}" + raise ValidationError( + f"Error: Call to unknown function '{name}' inside 'vars'-promise {location}", + node, ) - return 1 if ( state.promise_type == "vars" and state.attribute_name in ("action", "classes") and not is_body ): - _highlight_range(node, lines) - print( - f"Error: '{name}' is not a defined body. Only bodies may be called with '{state.attribute_name}' {location}" + raise ValidationError( + f"Error: '{name}' is not a defined body. Only bodies may be called with '{state.attribute_name}' {location}", + node, ) - return 1 if node.type == "attribute_name" and state.promise_type and state.attribute_name: promise_type_data = syntax_data.BUILTIN_PROMISE_TYPES.get( state.promise_type, {} ) if not promise_type_data: # Custom promise type - we cannot validate attribute name here. - return 0 + return promise_type_attrs = promise_type_data.get("attributes", {}) if state.attribute_name not in promise_type_attrs: - _highlight_range(node, lines) - print( - f"Error: Invalid attribute '{state.attribute_name}' for promise type '{state.promise_type}' {location}" + raise ValidationError( + f"Error: Invalid attribute '{state.attribute_name}' for promise type '{state.promise_type}' {location}", + node, ) - return 1 if ( state.block_keyword == "promise" and node.type == "attribute_name" and state.attribute_name not in (None, *PROMISE_BLOCK_ATTRIBUTES) ): - _highlight_range(node, lines) - print( - f"Error: Invalid attribute name '{state.attribute_name}' in '{state.block_name}' custom promise type definition {location}" + raise ValidationError( + f"Error: Invalid attribute name '{state.attribute_name}' in '{state.block_name}' custom promise type definition {location}", + node, ) - return 1 if node.type == "call": call, _, *args, _ = node.children # f ( a1 , a2 , a..N ) call = _text(call) if call in KNOWN_FAULTY_FUNCTION_DEFS: - return 0 + return args = list( filter(",".__ne__, iter(_text(x) for x in args if x.type != "comment")) @@ -811,7 +801,6 @@ def _lint_node( min_args = max_args = len(func.get("parameters", [])) if not (min_args <= len(args) <= max_args): - _highlight_range(node, lines) argc_str = ( f"at least {min_args}" if max_args == float("inf") @@ -821,10 +810,10 @@ def _lint_node( else str(max_args) ) ) - print( - f"Error: Expected {argc_str} arguments, received {len(args)} for function '{call}' {location}" + raise ValidationError( + f"Error: Expected {argc_str} arguments, received {len(args)} for function '{call}' {location}", + node, ) - return 1 qualified_name = _qualify(call, state.namespace) if ( @@ -835,14 +824,13 @@ def _lint_node( definitions = state.bundles[qualified_name] valid_counts = {len(d.get("parameters", [])) for d in definitions} if len(args) not in valid_counts: - _highlight_range(node, lines) counts = sorted(valid_counts) expected = " or ".join(str(c) for c in counts) - print( - f"Error: Expected {expected} arguments, received {len(args)} for bundle '{call}' {location}" + raise ValidationError( + f"Error: Expected {expected} arguments, received {len(args)} for bundle '{call}' {location}", + node, + [_definition_hint("bundle", call, definitions)], ) - _print_definition_hints("bundle", call, definitions) - return 1 if ( not state.inside_call and qualified_name in state.bodies @@ -851,32 +839,28 @@ def _lint_node( definitions = state.bodies[qualified_name] valid_counts = {len(d.get("parameters", [])) for d in definitions} if len(args) not in valid_counts: - _highlight_range(node, lines) counts = sorted(valid_counts) expected = " or ".join(str(c) for c in counts) - print( - f"Error: Expected {expected} arguments, received {len(args)} for body '{call}' {location}" + raise ValidationError( + f"Error: Expected {expected} arguments, received {len(args)} for body '{call}' {location}", + node, + [_definition_hint("body", call, definitions)], ) - _print_definition_hints("body", call, definitions) - return 1 if node.type == "half_promise": prev_sib = node.prev_named_sibling while prev_sib and prev_sib.type == "comment": prev_sib = prev_sib.prev_named_sibling prev_type = prev_sib.type if prev_sib else None if not state.macro: - _highlight_range(node, lines) - print( - f"Error: Found promise attribute with no parent-promiser outside of a macro {location}" + raise ValidationError( + f"Error: Found promise attribute with no parent-promiser outside of a macro {location}", + node, ) - return 1 elif prev_type != "macro": - _highlight_range(node, lines) - print( - f"Error: Multiple promise attributes with ending semicolon found inside macro '{state.macro}' {location}" + raise ValidationError( + f"Error: Multiple promise attributes with ending semicolon found inside macro '{state.macro}' {location}", + node, ) - return 1 - return 0 def _pass_fail_filename(filename: str, errors: int) -> str: @@ -905,7 +889,14 @@ def _lint(policy_file: PolicyFile, state: State, syntax_data: SyntaxData) -> int state.start_file(policy_file) for node in policy_file.nodes: state.navigate(node) - errors += _lint_node(node, policy_file, state, syntax_data) + try: + _lint_node(node, policy_file, state, syntax_data) + except ValidationError as e: + _highlight_range(e.node, policy_file.lines) + print(e.message) + for hint in e.hints: + print(hint) + errors += 1 message = _pass_fail_state(state, errors) state.end_file() if state.prefix: @@ -1150,12 +1141,12 @@ def _text(node: Node) -> str: return node.text.decode() -def _print_definition_hints(kind: str, name: str, definitions: list[dict]) -> None: - """Print a single 'Hint:' line, joining all definition locations with ' and '.""" +def _definition_hint(kind: str, name: str, definitions: list[dict]) -> str: + """Build a single 'Hint:' line, joining all definition locations with ' and '.""" locations = " and ".join( f"{d['filename']}:{d['line']}:{d['column']}" for d in definitions ) - print(f"Hint: The {kind} '{name}' is defined at {locations}") + return f"Hint: The {kind} '{name}' is defined at {locations}" def _walk_callback(node: Node, callback: Callable[[Node], int]) -> int: @@ -1252,6 +1243,20 @@ def __init__(self, filename: str, line: int, column: int): super().__init__(f"Syntax error in '{filename}' at {filename}:{line}:{column}") +class ValidationError(Exception): + """Raised by _lint_node when a linting check fails. + + Carries the node to highlight, the error message, and any hint lines so + the caller can render them. + """ + + def __init__(self, message: str, node: Node, hints: list[str] | None = None): + self.message = message + self.node = node + self.hints = hints or [] + super().__init__(message) + + def check_policy_syntax(tree: Tree, filename: str) -> None: """Check a parsed tree for syntax errors. From ca5579c798035c2fc04eaa54a25c5b0383bc1168 Mon Sep 17 00:00:00 2001 From: Ole Herman Schumacher Elgesem Date: Fri, 1 May 2026 03:13:00 +0200 Subject: [PATCH 07/16] lint.py: Moved half_promise linting into separate function Signed-off-by: Ole Herman Schumacher Elgesem --- src/cfengine_cli/lint.py | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/src/cfengine_cli/lint.py b/src/cfengine_cli/lint.py index 86e88d2..daaf4b8 100644 --- a/src/cfengine_cli/lint.py +++ b/src/cfengine_cli/lint.py @@ -571,6 +571,25 @@ def _discover(policy_file: PolicyFile, state: State) -> int: return 0 +def _lint_half_promise(node: Node, state: State, location: str): + assert node.type == "half_promise" + + prev_sib = node.prev_named_sibling + while prev_sib and prev_sib.type == "comment": + prev_sib = prev_sib.prev_named_sibling + prev_type = prev_sib.type if prev_sib else None + if not state.macro: + raise ValidationError( + f"Error: Found promise attribute with no parent-promiser outside of a macro {location}", + node, + ) + elif prev_type != "macro": + raise ValidationError( + f"Error: Multiple promise attributes with ending semicolon found inside macro '{state.macro}' {location}", + node, + ) + + def _lint_node( node: Node, policy_file: PolicyFile, state: State, syntax_data: SyntaxData ) -> None: @@ -847,20 +866,7 @@ def _lint_node( [_definition_hint("body", call, definitions)], ) if node.type == "half_promise": - prev_sib = node.prev_named_sibling - while prev_sib and prev_sib.type == "comment": - prev_sib = prev_sib.prev_named_sibling - prev_type = prev_sib.type if prev_sib else None - if not state.macro: - raise ValidationError( - f"Error: Found promise attribute with no parent-promiser outside of a macro {location}", - node, - ) - elif prev_type != "macro": - raise ValidationError( - f"Error: Multiple promise attributes with ending semicolon found inside macro '{state.macro}' {location}", - node, - ) + _lint_half_promise(node, state, location) def _pass_fail_filename(filename: str, errors: int) -> str: From 347f3dbb09ab3a0cde7da0b28d75c862d99c87d9 Mon Sep 17 00:00:00 2001 From: Ole Herman Schumacher Elgesem Date: Fri, 1 May 2026 03:15:38 +0200 Subject: [PATCH 08/16] lint.py: Moved call linting into separate function Signed-off-by: Ole Herman Schumacher Elgesem --- src/cfengine_cli/lint.py | 164 +++++++++++++++++++-------------------- 1 file changed, 82 insertions(+), 82 deletions(-) diff --git a/src/cfengine_cli/lint.py b/src/cfengine_cli/lint.py index daaf4b8..8a62252 100644 --- a/src/cfengine_cli/lint.py +++ b/src/cfengine_cli/lint.py @@ -571,6 +571,87 @@ def _discover(policy_file: PolicyFile, state: State) -> int: return 0 +def _lint_call(node: Node, state: State, location: str, syntax_data: SyntaxData): + call, _, *args, _ = node.children # f ( a1 , a2 , a..N ) + call = _text(call) + if call in KNOWN_FAULTY_FUNCTION_DEFS: + return + + args = list(filter(",".__ne__, iter(_text(x) for x in args if x.type != "comment"))) + + if call in syntax_data.BUILTIN_FUNCTIONS and ( + state.inside_call + or ( + state.attribute_name not in IMPLIES_BUNDLE + and state.attribute_name not in IMPLIES_BODY + ) + ): + func = syntax_data.BUILTIN_FUNCTIONS.get(call, {}) + variadic = func.get("variadic", True) + # variadic meaning variable amount of arguments allowed + # -1, -1 // default -- all required, aka. non-variadic func + # 1, -1 // 1-n + # 0, -1 // 0-n + # 2, 3 // 2-3 + min_args = func.get("minArgs", -1) + max_args = func.get("maxArgs", -1) + if variadic: + assert min_args != -1 + assert min_args != max_args + if max_args == -1: + max_args = float("inf") # N args allowed + else: + assert min_args == -1 and max_args == -1 + # If min args -1 (meaning all required), max should be the same + # All args required, use len of parameter list + min_args = max_args = len(func.get("parameters", [])) + + if not (min_args <= len(args) <= max_args): + argc_str = ( + f"at least {min_args}" + if max_args == float("inf") + else ( + f"{min_args}-{max_args}" if min_args != max_args else str(max_args) + ) + ) + raise ValidationError( + f"Error: Expected {argc_str} arguments, received {len(args)} for function '{call}' {location}", + node, + ) + + qualified_name = _qualify(call, state.namespace) + if ( + not state.inside_call + and qualified_name in state.bundles + and state.attribute_name not in IMPLIES_BODY + ): + definitions = state.bundles[qualified_name] + valid_counts = {len(d.get("parameters", [])) for d in definitions} + if len(args) not in valid_counts: + counts = sorted(valid_counts) + expected = " or ".join(str(c) for c in counts) + raise ValidationError( + f"Error: Expected {expected} arguments, received {len(args)} for bundle '{call}' {location}", + node, + [_definition_hint("bundle", call, definitions)], + ) + if ( + not state.inside_call + and qualified_name in state.bodies + and state.attribute_name not in IMPLIES_BUNDLE + ): + definitions = state.bodies[qualified_name] + valid_counts = {len(d.get("parameters", [])) for d in definitions} + if len(args) not in valid_counts: + counts = sorted(valid_counts) + expected = " or ".join(str(c) for c in counts) + raise ValidationError( + f"Error: Expected {expected} arguments, received {len(args)} for body '{call}' {location}", + node, + [_definition_hint("body", call, definitions)], + ) + + def _lint_half_promise(node: Node, state: State, location: str): assert node.type == "half_promise" @@ -783,88 +864,7 @@ def _lint_node( node, ) if node.type == "call": - call, _, *args, _ = node.children # f ( a1 , a2 , a..N ) - call = _text(call) - if call in KNOWN_FAULTY_FUNCTION_DEFS: - return - - args = list( - filter(",".__ne__, iter(_text(x) for x in args if x.type != "comment")) - ) - - if call in syntax_data.BUILTIN_FUNCTIONS and ( - state.inside_call - or ( - state.attribute_name not in IMPLIES_BUNDLE - and state.attribute_name not in IMPLIES_BODY - ) - ): - func = syntax_data.BUILTIN_FUNCTIONS.get(call, {}) - variadic = func.get("variadic", True) - # variadic meaning variable amount of arguments allowed - # -1, -1 // default -- all required, aka. non-variadic func - # 1, -1 // 1-n - # 0, -1 // 0-n - # 2, 3 // 2-3 - min_args = func.get("minArgs", -1) - max_args = func.get("maxArgs", -1) - if variadic: - assert min_args != -1 - assert min_args != max_args - if max_args == -1: - max_args = float("inf") # N args allowed - else: - assert min_args == -1 and max_args == -1 - # If min args -1 (meaning all required), max should be the same - # All args required, use len of parameter list - min_args = max_args = len(func.get("parameters", [])) - - if not (min_args <= len(args) <= max_args): - argc_str = ( - f"at least {min_args}" - if max_args == float("inf") - else ( - f"{min_args}-{max_args}" - if min_args != max_args - else str(max_args) - ) - ) - raise ValidationError( - f"Error: Expected {argc_str} arguments, received {len(args)} for function '{call}' {location}", - node, - ) - - qualified_name = _qualify(call, state.namespace) - if ( - not state.inside_call - and qualified_name in state.bundles - and state.attribute_name not in IMPLIES_BODY - ): - definitions = state.bundles[qualified_name] - valid_counts = {len(d.get("parameters", [])) for d in definitions} - if len(args) not in valid_counts: - counts = sorted(valid_counts) - expected = " or ".join(str(c) for c in counts) - raise ValidationError( - f"Error: Expected {expected} arguments, received {len(args)} for bundle '{call}' {location}", - node, - [_definition_hint("bundle", call, definitions)], - ) - if ( - not state.inside_call - and qualified_name in state.bodies - and state.attribute_name not in IMPLIES_BUNDLE - ): - definitions = state.bodies[qualified_name] - valid_counts = {len(d.get("parameters", [])) for d in definitions} - if len(args) not in valid_counts: - counts = sorted(valid_counts) - expected = " or ".join(str(c) for c in counts) - raise ValidationError( - f"Error: Expected {expected} arguments, received {len(args)} for body '{call}' {location}", - node, - [_definition_hint("body", call, definitions)], - ) + _lint_call(node, state, location, syntax_data) if node.type == "half_promise": _lint_half_promise(node, state, location) From 93137948d5dfa43d996ae054eb8de678ffb2e7a1 Mon Sep 17 00:00:00 2001 From: Ole Herman Schumacher Elgesem Date: Fri, 1 May 2026 03:36:52 +0200 Subject: [PATCH 09/16] lint.py: Moved attribute_name linting to separate function Signed-off-by: Ole Herman Schumacher Elgesem --- src/cfengine_cli/lint.py | 60 +++++++++++++++++++++++----------------- 1 file changed, 34 insertions(+), 26 deletions(-) diff --git a/src/cfengine_cli/lint.py b/src/cfengine_cli/lint.py index 8a62252..0012ddc 100644 --- a/src/cfengine_cli/lint.py +++ b/src/cfengine_cli/lint.py @@ -571,6 +571,37 @@ def _discover(policy_file: PolicyFile, state: State) -> int: return 0 +def _lint_attribute_name( + node: Node, state: State, location: str, syntax_data: SyntaxData +): + assert node.type == "attribute_name" + if _text(node) == "ifvarclass": + raise ValidationError( + f"Deprecation: Use 'if' instead of 'ifvarclass' {location}", node + ) + if state.promise_type and state.attribute_name: + promise_type_data = syntax_data.BUILTIN_PROMISE_TYPES.get( + state.promise_type, {} + ) + if not promise_type_data: + # Custom promise type - we cannot validate attribute name here. + return + promise_type_attrs = promise_type_data.get("attributes", {}) + if state.attribute_name not in promise_type_attrs: + raise ValidationError( + f"Error: Invalid attribute '{state.attribute_name}' for promise type '{state.promise_type}' {location}", + node, + ) + if state.block_keyword == "promise" and state.attribute_name not in ( + None, + *PROMISE_BLOCK_ATTRIBUTES, + ): + raise ValidationError( + f"Error: Invalid attribute name '{state.attribute_name}' in '{state.block_name}' custom promise type definition {location}", + node, + ) + + def _lint_call(node: Node, state: State, location: str, syntax_data: SyntaxData): call, _, *args, _ = node.children # f ( a1 , a2 , a..N ) call = _text(call) @@ -686,10 +717,6 @@ def _lint_node( column = node.range.start_point[1] + 1 location = state.get_location_extended(line, column) - if node.type == "attribute_name" and _text(node) == "ifvarclass": - raise ValidationError( - f"Deprecation: Use 'if' instead of 'ifvarclass' {location}", node - ) if node.type == "promise_guard": assert _text(node) and len(_text(node)) > 1 and _text(node)[-1] == ":" promise_type = _text(node)[0:-1] @@ -841,28 +868,9 @@ def _lint_node( f"Error: '{name}' is not a defined body. Only bodies may be called with '{state.attribute_name}' {location}", node, ) - if node.type == "attribute_name" and state.promise_type and state.attribute_name: - promise_type_data = syntax_data.BUILTIN_PROMISE_TYPES.get( - state.promise_type, {} - ) - if not promise_type_data: - # Custom promise type - we cannot validate attribute name here. - return - promise_type_attrs = promise_type_data.get("attributes", {}) - if state.attribute_name not in promise_type_attrs: - raise ValidationError( - f"Error: Invalid attribute '{state.attribute_name}' for promise type '{state.promise_type}' {location}", - node, - ) - if ( - state.block_keyword == "promise" - and node.type == "attribute_name" - and state.attribute_name not in (None, *PROMISE_BLOCK_ATTRIBUTES) - ): - raise ValidationError( - f"Error: Invalid attribute name '{state.attribute_name}' in '{state.block_name}' custom promise type definition {location}", - node, - ) + + if node.type == "attribute_name": + _lint_attribute_name(node, state, location, syntax_data) if node.type == "call": _lint_call(node, state, location, syntax_data) if node.type == "half_promise": From 45539ec5f0c75729b35c9e219ab58747abc8ffc4 Mon Sep 17 00:00:00 2001 From: Ole Herman Schumacher Elgesem Date: Fri, 1 May 2026 03:50:29 +0200 Subject: [PATCH 10/16] lint.py: Moved calling_identifier linting to separate function Signed-off-by: Ole Herman Schumacher Elgesem --- src/cfengine_cli/lint.py | 162 +++++++++++++++++++-------------------- 1 file changed, 81 insertions(+), 81 deletions(-) diff --git a/src/cfengine_cli/lint.py b/src/cfengine_cli/lint.py index 0012ddc..84ecf3b 100644 --- a/src/cfengine_cli/lint.py +++ b/src/cfengine_cli/lint.py @@ -571,6 +571,86 @@ def _discover(policy_file: PolicyFile, state: State) -> int: return 0 +def _lint_calling_identifier( + node: Node, state: State, location: str, syntax_data: SyntaxData +): + assert node.type == "calling_identifier" + name = _text(node) + qualified_name = _qualify(name, state.namespace) + is_bundle = qualified_name in state.bundles + is_body = qualified_name in state.bodies + is_function = name in syntax_data.BUILTIN_FUNCTIONS + + if state.inside_call: + # Nested calls must be built-in functions - the surrounding + # attribute's IMPLIES_BUNDLE/IMPLIES_BODY only applies to the + # outermost call. + if not is_function: + if is_bundle: + error = f"Error: Expected a built-in function but '{name}' is a bundle {location}" + elif is_body: + error = f"Error: Expected a built-in function but '{name}' is a body {location}" + else: + error = f"Error: Call to unknown function '{name}' {location}" + raise ValidationError(error, node) + return + if state.strict and is_bundle and state.promise_type in state.custom_promise_types: + raise ValidationError( + f"Error: Call to bundle '{name}' inside custom promise: '{state.promise_type}' {location}", + node, + ) + if state.strict: + implies_bundle = state.attribute_name in IMPLIES_BUNDLE + implies_body = state.attribute_name in IMPLIES_BODY + + error = None + if implies_bundle and not is_bundle: + if is_body: + error = f"Error: Expected a bundle but '{name}' is a body {location}" + elif is_function: + error = f"Error: Expected a bundle but '{name}' is a built-in function {location}" + else: + error = f"Error: Call to unknown bundle '{name}' {location}" + elif implies_body and not is_body: + if is_bundle: + error = f"Error: Expected a body but '{name}' is a bundle {location}" + elif is_function: + error = f"Error: Expected a body but '{name}' is a built-in function {location}" + else: + error = f"Error: Call to unknown body '{name}' {location}" + elif ( + not implies_bundle + and not implies_body + and not is_bundle + and not is_body + and not is_function + ): + error = ( + f"Error: Call to unknown function / bundle / body '{name}' {location}" + ) + + if error: + raise ValidationError(error, node) + if ( + not is_function + and state.promise_type == "vars" + and state.attribute_name not in ("action", "classes") + ): + raise ValidationError( + f"Error: Call to unknown function '{name}' inside 'vars'-promise {location}", + node, + ) + if ( + state.promise_type == "vars" + and state.attribute_name in ("action", "classes") + and not is_body + ): + raise ValidationError( + f"Error: '{name}' is not a defined body. Only bodies may be called with '{state.attribute_name}' {location}", + node, + ) + + def _lint_attribute_name( node: Node, state: State, location: str, syntax_data: SyntaxData ): @@ -788,87 +868,7 @@ def _lint_node( value_nodes[-1], ) if node.type == "calling_identifier": - name = _text(node) - qualified_name = _qualify(name, state.namespace) - is_bundle = qualified_name in state.bundles - is_body = qualified_name in state.bodies - is_function = name in syntax_data.BUILTIN_FUNCTIONS - - if state.inside_call: - # Nested calls must be built-in functions - the surrounding - # attribute's IMPLIES_BUNDLE/IMPLIES_BODY only applies to the - # outermost call. - if not is_function: - if is_bundle: - error = f"Error: Expected a built-in function but '{name}' is a bundle {location}" - elif is_body: - error = f"Error: Expected a built-in function but '{name}' is a body {location}" - else: - error = f"Error: Call to unknown function '{name}' {location}" - raise ValidationError(error, node) - return - if ( - state.strict - and is_bundle - and state.promise_type in state.custom_promise_types - ): - raise ValidationError( - f"Error: Call to bundle '{name}' inside custom promise: '{state.promise_type}' {location}", - node, - ) - if state.strict: - implies_bundle = state.attribute_name in IMPLIES_BUNDLE - implies_body = state.attribute_name in IMPLIES_BODY - - error = None - if implies_bundle and not is_bundle: - if is_body: - error = ( - f"Error: Expected a bundle but '{name}' is a body {location}" - ) - elif is_function: - error = f"Error: Expected a bundle but '{name}' is a built-in function {location}" - else: - error = f"Error: Call to unknown bundle '{name}' {location}" - elif implies_body and not is_body: - if is_bundle: - error = ( - f"Error: Expected a body but '{name}' is a bundle {location}" - ) - elif is_function: - error = f"Error: Expected a body but '{name}' is a built-in function {location}" - else: - error = f"Error: Call to unknown body '{name}' {location}" - elif ( - not implies_bundle - and not implies_body - and not is_bundle - and not is_body - and not is_function - ): - error = f"Error: Call to unknown function / bundle / body '{name}' {location}" - - if error: - raise ValidationError(error, node) - if ( - not is_function - and state.promise_type == "vars" - and state.attribute_name not in ("action", "classes") - ): - raise ValidationError( - f"Error: Call to unknown function '{name}' inside 'vars'-promise {location}", - node, - ) - if ( - state.promise_type == "vars" - and state.attribute_name in ("action", "classes") - and not is_body - ): - raise ValidationError( - f"Error: '{name}' is not a defined body. Only bodies may be called with '{state.attribute_name}' {location}", - node, - ) - + _lint_calling_identifier(node, state, location, syntax_data) if node.type == "attribute_name": _lint_attribute_name(node, state, location, syntax_data) if node.type == "call": From c3669933aeb324a0084c885240b5aa706a529192 Mon Sep 17 00:00:00 2001 From: Ole Herman Schumacher Elgesem Date: Fri, 1 May 2026 04:00:25 +0200 Subject: [PATCH 11/16] lint.py: Moved promise linting to separate function Signed-off-by: Ole Herman Schumacher Elgesem --- src/cfengine_cli/lint.py | 63 ++++++++++++++++++++++------------------ 1 file changed, 34 insertions(+), 29 deletions(-) diff --git a/src/cfengine_cli/lint.py b/src/cfengine_cli/lint.py index 84ecf3b..b1e9084 100644 --- a/src/cfengine_cli/lint.py +++ b/src/cfengine_cli/lint.py @@ -570,6 +570,38 @@ def _discover(policy_file: PolicyFile, state: State) -> int: state.end_file() return 0 +def _lint_promise(node: Node, state: State, location: str, _syntax_data: SyntaxData): + assert node.type == "promise" + if state.promise_type == "vars": + attribute_nodes = [x for x in node.children if x.type == "attribute"] + # Attributes are children of a promise, and attribute names are children of attributes + # Need to iterate inside to find the attribute name (data, ilist, int, etc.) + value_nodes = [] + for attr in attribute_nodes: + for child in attr.children: + if child.type != "attribute_name": + continue + if _text(child) in VARS_TYPES: + # Ignore the other attributes which are not values + value_nodes.append(child) + + if not value_nodes: + # None of vars_types were found + raise ValidationError( + f"Error: Missing value for vars promise {_text(node)[:-1]} {location}", + node, + ) + + if len(value_nodes) > 1: + # Too many of vars_types was found + # TODO: We could improve _highlight_range to highlight multiple nodes in a nice way + nodes = ", ".join([_text(x) for x in value_nodes]) + raise ValidationError( + f"Error: Mutually exclusive attribute values ({nodes})" + f" for a single promiser inside vars-promise {location}", + value_nodes[-1], + ) + def _lint_calling_identifier( node: Node, state: State, location: str, syntax_data: SyntaxData @@ -838,35 +870,8 @@ def _lint_node( f"Error: {'Bundle' if 'bundle' in node.type else 'Body'} '{_text(node)}' conflicts with built-in function with the same name {location}", node, ) - if state.promise_type == "vars" and node.type == "promise": - attribute_nodes = [x for x in node.children if x.type == "attribute"] - # Attributes are children of a promise, and attribute names are children of attributes - # Need to iterate inside to find the attribute name (data, ilist, int, etc.) - value_nodes = [] - for attr in attribute_nodes: - for child in attr.children: - if child.type != "attribute_name": - continue - if _text(child) in VARS_TYPES: - # Ignore the other attributes which are not values - value_nodes.append(child) - - if not value_nodes: - # None of vars_types were found - raise ValidationError( - f"Error: Missing value for vars promise {_text(node)[:-1]} {location}", - node, - ) - - if len(value_nodes) > 1: - # Too many of vars_types was found - # TODO: We could improve _highlight_range to highlight multiple nodes in a nice way - nodes = ", ".join([_text(x) for x in value_nodes]) - raise ValidationError( - f"Error: Mutually exclusive attribute values ({nodes})" - f" for a single promiser inside vars-promise {location}", - value_nodes[-1], - ) + if node.type == "promise": + _lint_promise(node, state, location, syntax_data) if node.type == "calling_identifier": _lint_calling_identifier(node, state, location, syntax_data) if node.type == "attribute_name": From fb0c8f31955f72066715fac5e3a53bff991ae3c9 Mon Sep 17 00:00:00 2001 From: Ole Herman Schumacher Elgesem Date: Fri, 1 May 2026 04:03:53 +0200 Subject: [PATCH 12/16] lint.py: Moved linting of block names to separate function Signed-off-by: Ole Herman Schumacher Elgesem --- src/cfengine_cli/lint.py | 42 ++++++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/src/cfengine_cli/lint.py b/src/cfengine_cli/lint.py index b1e9084..5f6bd07 100644 --- a/src/cfengine_cli/lint.py +++ b/src/cfengine_cli/lint.py @@ -570,6 +570,29 @@ def _discover(policy_file: PolicyFile, state: State) -> int: state.end_file() return 0 + +def _lint_block_name(node: Node, state: State, location: str, syntax_data: SyntaxData): + assert node.type in ("bundle_block_name", "body_block_name", "promise_block_name") + + if node.type == "bundle_block_name" and _text(node) != _text(node).lower(): + raise ValidationError( + f"Convention: Bundle name should be lowercase {location}", node + ) + if node.type == "promise_block_name" and _text(node) != _text(node).lower(): + raise ValidationError( + f"Convention: Promise type should be lowercase {location}", node + ) + if state.strict and ( + node.type in ("bundle_block_name", "body_block_name") + and _text(node) in syntax_data.BUILTIN_FUNCTIONS + and _text(node) not in KNOWN_FAULTY_FUNCTION_DEFS + ): + raise ValidationError( + f"Error: {'Bundle' if 'bundle' in node.type else 'Body'} '{_text(node)}' conflicts with built-in function with the same name {location}", + node, + ) + + def _lint_promise(node: Node, state: State, location: str, _syntax_data: SyntaxData): assert node.type == "promise" if state.promise_type == "vars": @@ -845,14 +868,6 @@ def _lint_node( raise ValidationError( f"Error: Undefined promise type '{promise_type}' {location}", node ) - if node.type == "bundle_block_name" and _text(node) != _text(node).lower(): - raise ValidationError( - f"Convention: Bundle name should be lowercase {location}", node - ) - if node.type == "promise_block_name" and _text(node) != _text(node).lower(): - raise ValidationError( - f"Convention: Promise type should be lowercase {location}", node - ) if ( node.type == "bundle_block_type" and _text(node) not in syntax_data.BUILTIN_BUNDLE_TYPES @@ -861,15 +876,8 @@ def _lint_node( f"Error: Bundle type must be one of ({', '.join(syntax_data.BUILTIN_BUNDLE_TYPES)}), not '{_text(node)}' {location}", node, ) - if state.strict and ( - node.type in ("bundle_block_name", "body_block_name") - and _text(node) in syntax_data.BUILTIN_FUNCTIONS - and _text(node) not in KNOWN_FAULTY_FUNCTION_DEFS - ): - raise ValidationError( - f"Error: {'Bundle' if 'bundle' in node.type else 'Body'} '{_text(node)}' conflicts with built-in function with the same name {location}", - node, - ) + if node.type in ("bundle_block_name", "body_block_name", "promise_block_name"): + _lint_block_name(node, state, location, syntax_data) if node.type == "promise": _lint_promise(node, state, location, syntax_data) if node.type == "calling_identifier": From 951c3a64cdd7b8cdf388fce21e73f54cc98adb3a Mon Sep 17 00:00:00 2001 From: Ole Herman Schumacher Elgesem Date: Fri, 1 May 2026 04:08:50 +0200 Subject: [PATCH 13/16] lint.py: Moved block type linting to separate function Signed-off-by: Ole Herman Schumacher Elgesem --- src/cfengine_cli/lint.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/cfengine_cli/lint.py b/src/cfengine_cli/lint.py index 5f6bd07..a1c2153 100644 --- a/src/cfengine_cli/lint.py +++ b/src/cfengine_cli/lint.py @@ -571,6 +571,17 @@ def _discover(policy_file: PolicyFile, state: State) -> int: return 0 +def _lint_block_type(node: Node, state: State, location: str, syntax_data: SyntaxData): + if ( + node.type == "bundle_block_type" + and _text(node) not in syntax_data.BUILTIN_BUNDLE_TYPES + ): + raise ValidationError( + f"Error: Bundle type must be one of ({', '.join(syntax_data.BUILTIN_BUNDLE_TYPES)}), not '{_text(node)}' {location}", + node, + ) + + def _lint_block_name(node: Node, state: State, location: str, syntax_data: SyntaxData): assert node.type in ("bundle_block_name", "body_block_name", "promise_block_name") @@ -868,14 +879,9 @@ def _lint_node( raise ValidationError( f"Error: Undefined promise type '{promise_type}' {location}", node ) - if ( - node.type == "bundle_block_type" - and _text(node) not in syntax_data.BUILTIN_BUNDLE_TYPES - ): - raise ValidationError( - f"Error: Bundle type must be one of ({', '.join(syntax_data.BUILTIN_BUNDLE_TYPES)}), not '{_text(node)}' {location}", - node, - ) + + if node.type in ("bundle_block_type", "body_block_type", "promise_block_type"): + _lint_block_type(node, state, location, syntax_data) if node.type in ("bundle_block_name", "body_block_name", "promise_block_name"): _lint_block_name(node, state, location, syntax_data) if node.type == "promise": From 1960bb6edbb5fc0c0fdcca06341aa1b9c4126f1f Mon Sep 17 00:00:00 2001 From: Ole Herman Schumacher Elgesem Date: Fri, 1 May 2026 04:10:36 +0200 Subject: [PATCH 14/16] lint.py: Moved linting of promise guard to separate function Signed-off-by: Ole Herman Schumacher Elgesem --- src/cfengine_cli/lint.py | 37 +++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/src/cfengine_cli/lint.py b/src/cfengine_cli/lint.py index a1c2153..1787494 100644 --- a/src/cfengine_cli/lint.py +++ b/src/cfengine_cli/lint.py @@ -571,6 +571,26 @@ def _discover(policy_file: PolicyFile, state: State) -> int: return 0 +def _lint_promise_guard( + node: Node, state: State, location: str, syntax_data: SyntaxData +): + assert _text(node) and len(_text(node)) > 1 and _text(node)[-1] == ":" + promise_type = _text(node)[0:-1] + if promise_type in syntax_data.DEPRECATED_PROMISE_TYPES: + raise ValidationError( + f"Deprecation: Promise type '{promise_type}' is deprecated {location}", + node, + ) + if ( + state.strict + and promise_type not in syntax_data.BUILTIN_PROMISE_TYPES + and promise_type not in state.custom_promise_types + ): + raise ValidationError( + f"Error: Undefined promise type '{promise_type}' {location}", node + ) + + def _lint_block_type(node: Node, state: State, location: str, syntax_data: SyntaxData): if ( node.type == "bundle_block_type" @@ -864,22 +884,7 @@ def _lint_node( location = state.get_location_extended(line, column) if node.type == "promise_guard": - assert _text(node) and len(_text(node)) > 1 and _text(node)[-1] == ":" - promise_type = _text(node)[0:-1] - if promise_type in syntax_data.DEPRECATED_PROMISE_TYPES: - raise ValidationError( - f"Deprecation: Promise type '{promise_type}' is deprecated {location}", - node, - ) - if ( - state.strict - and promise_type not in syntax_data.BUILTIN_PROMISE_TYPES - and promise_type not in state.custom_promise_types - ): - raise ValidationError( - f"Error: Undefined promise type '{promise_type}' {location}", node - ) - + _lint_promise_guard(node, state, location, syntax_data) if node.type in ("bundle_block_type", "body_block_type", "promise_block_type"): _lint_block_type(node, state, location, syntax_data) if node.type in ("bundle_block_name", "body_block_name", "promise_block_name"): From 3cf84bf49dd24b7e77ea5333f7f79afd55fb7497 Mon Sep 17 00:00:00 2001 From: Ole Herman Schumacher Elgesem Date: Fri, 1 May 2026 04:19:07 +0200 Subject: [PATCH 15/16] lint.py: Reorganized _lint_node and added comments Signed-off-by: Ole Herman Schumacher Elgesem --- src/cfengine_cli/lint.py | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/src/cfengine_cli/lint.py b/src/cfengine_cli/lint.py index 1787494..6a19e4e 100644 --- a/src/cfengine_cli/lint.py +++ b/src/cfengine_cli/lint.py @@ -883,22 +883,37 @@ def _lint_node( column = node.range.start_point[1] + 1 location = state.get_location_extended(line, column) - if node.type == "promise_guard": - _lint_promise_guard(node, state, location, syntax_data) if node.type in ("bundle_block_type", "body_block_type", "promise_block_type"): + # The type is what comes after body / bundle / promise keyword + # and before the name. Ex: common, agent, copy_from, classes _lint_block_type(node, state, location, syntax_data) if node.type in ("bundle_block_name", "body_block_name", "promise_block_name"): + # The name of the bundle, body, or promise type (where it's defined). _lint_block_name(node, state, location, syntax_data) + if node.type == "promise_guard": + # The promise guard is the beginning of a section inside a bundle, + # or, in other words, the promise type + one colon + _lint_promise_guard(node, state, location, syntax_data) if node.type == "promise": + # The promise node has the promiser, stakeholder, and all the attributes + # inside (as children). _lint_promise(node, state, location, syntax_data) - if node.type == "calling_identifier": - _lint_calling_identifier(node, state, location, syntax_data) + if node.type == "half_promise": + # A half promise is an artifact of how a macro can split up a promise + # into 2 branching parts (3 parts in total). + _lint_half_promise(node, state, location) if node.type == "attribute_name": + # Attribute name nodes refer to all the cases; inside a body, inside + # a promise block and inside a bundle (inside a promise). _lint_attribute_name(node, state, location, syntax_data) if node.type == "call": + # A call is a bare name (not inside quotes) plus parentheses, + # optionally with arguments inside, or even nested function calls. _lint_call(node, state, location, syntax_data) - if node.type == "half_promise": - _lint_half_promise(node, state, location) + if node.type == "calling_identifier": + # A calling identifier is the name of the function / body / bundle + # that is being called, the part before the parentheses + _lint_calling_identifier(node, state, location, syntax_data) def _pass_fail_filename(filename: str, errors: int) -> str: From 1c8157b59886065c7ad089d638e6eb1de6c486a3 Mon Sep 17 00:00:00 2001 From: Ole Herman Schumacher Elgesem Date: Fri, 1 May 2026 04:27:40 +0200 Subject: [PATCH 16/16] Added docstrings Co-authored-by: Claude Opus 4.7 (1M context) Signed-off-by: Ole Herman Schumacher Elgesem --- src/cfengine_cli/lint.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/cfengine_cli/lint.py b/src/cfengine_cli/lint.py index 6a19e4e..fb29a85 100644 --- a/src/cfengine_cli/lint.py +++ b/src/cfengine_cli/lint.py @@ -78,6 +78,7 @@ class SyntaxData: BUILTIN_FUNCTIONS = {} def __init__(self): + """Load the bundled syntax-description.json and derive lookup dicts.""" self._data_dict = self._load_syntax_description() self._derive_syntax_dicts(self._data_dict) @@ -172,6 +173,11 @@ class PolicyFile: """ def __init__(self, filename: str, snippet: Snippet | None = None): + """Parse the policy file at `filename` and flatten its syntax tree. + + `snippet` is set when the file is a temporary file extracted from a + markdown code block, so error messages can refer back to the original. + """ self.filename = filename tree, lines, original_data = _parse_policy_file(filename) self.tree = tree @@ -574,6 +580,7 @@ def _discover(policy_file: PolicyFile, state: State) -> int: def _lint_promise_guard( node: Node, state: State, location: str, syntax_data: SyntaxData ): + """Check that a promise type guard (e.g. `vars:`) for deprecation or unknown type.""" assert _text(node) and len(_text(node)) > 1 and _text(node)[-1] == ":" promise_type = _text(node)[0:-1] if promise_type in syntax_data.DEPRECATED_PROMISE_TYPES: @@ -592,6 +599,7 @@ def _lint_promise_guard( def _lint_block_type(node: Node, state: State, location: str, syntax_data: SyntaxData): + """Check that a block type (e.g. `agent` in `bundle agent main`) is valid.""" if ( node.type == "bundle_block_type" and _text(node) not in syntax_data.BUILTIN_BUNDLE_TYPES @@ -603,6 +611,7 @@ def _lint_block_type(node: Node, state: State, location: str, syntax_data: Synta def _lint_block_name(node: Node, state: State, location: str, syntax_data: SyntaxData): + """Check that a block name follows conventions and doesn't shadow a built-in.""" assert node.type in ("bundle_block_name", "body_block_name", "promise_block_name") if node.type == "bundle_block_name" and _text(node) != _text(node).lower(): @@ -625,6 +634,7 @@ def _lint_block_name(node: Node, state: State, location: str, syntax_data: Synta def _lint_promise(node: Node, state: State, location: str, _syntax_data: SyntaxData): + """Check that a vars-promise has exactly one value-typed attribute (string, slist, ...).""" assert node.type == "promise" if state.promise_type == "vars": attribute_nodes = [x for x in node.children if x.type == "attribute"] @@ -660,6 +670,13 @@ def _lint_promise(node: Node, state: State, location: str, _syntax_data: SyntaxD def _lint_calling_identifier( node: Node, state: State, location: str, syntax_data: SyntaxData ): + """Check that a function/bundle/body call name resolves correctly. + + Behavior depends on context: nested calls must be built-in functions, + `IMPLIES_BUNDLE` / `IMPLIES_BODY` attributes restrict the kind of + definition allowed, and a few rules apply only inside vars-promises and + custom promise types. + """ assert node.type == "calling_identifier" name = _text(node) qualified_name = _qualify(name, state.namespace) @@ -740,6 +757,8 @@ def _lint_calling_identifier( def _lint_attribute_name( node: Node, state: State, location: str, syntax_data: SyntaxData ): + """Check an attribute name for deprecations and validity according to the + surrounding promise type.""" assert node.type == "attribute_name" if _text(node) == "ifvarclass": raise ValidationError( @@ -769,6 +788,7 @@ def _lint_attribute_name( def _lint_call(node: Node, state: State, location: str, syntax_data: SyntaxData): + """Check a call's argument count against its built-in / bundle / body signature.""" call, _, *args, _ = node.children # f ( a1 , a2 , a..N ) call = _text(call) if call in KNOWN_FAULTY_FUNCTION_DEFS: @@ -850,6 +870,11 @@ def _lint_call(node: Node, state: State, location: str, syntax_data: SyntaxData) def _lint_half_promise(node: Node, state: State, location: str): + """Check if a half-promise (a promise split by a macro) is well-formed. + + Half-promises are only valid as macro branches, and only one half-promise + is allowed per macro branch. + """ assert node.type == "half_promise" prev_sib = node.prev_named_sibling @@ -1011,6 +1036,10 @@ def filter_filenames(filenames: Iterable[str], args: list[str]) -> Iterable[str] def _lint_check_args(args: list[str]): + """Validate user-supplied paths exist, are file/folder, and have a supported extension. + + Raises UserError on invalid input. + """ for i, arg in enumerate(args): if not os.path.exists(arg): raise UserError(f"'{arg}' does not exist") @@ -1290,6 +1319,7 @@ class PolicySyntaxError(Exception): """Raised when a policy file has syntax errors and cannot be formatted.""" def __init__(self, filename: str, line: int, column: int): + """Record the location of the syntax error and format a default message.""" self.filename = filename self.line = line self.column = column @@ -1304,6 +1334,7 @@ class ValidationError(Exception): """ def __init__(self, message: str, node: Node, hints: list[str] | None = None): + """Store the error message, the node to highlight, and any hint lines.""" self.message = message self.node = node self.hints = hints or [] @@ -1368,4 +1399,5 @@ def lint_policy_file_snippet( def lint_json(filename: str) -> int: + """Lint a single JSON file (cfbs.json gets cfbs validation, others basic parse).""" return _lint_json_selector(filename)