diff --git a/tests/integration/test_variable_definitions.py b/tests/integration/test_variable_definitions.py index 7cbd0759..a5020433 100644 --- a/tests/integration/test_variable_definitions.py +++ b/tests/integration/test_variable_definitions.py @@ -16,26 +16,19 @@ class TestVariableDefinitions(unittest.TestCase): glob(os.path.join(self.project_root, 'roles/*/defaults/*.yml')) + glob(os.path.join(self.project_root, 'group_vars/all/*.yml')) ) - # Valid file extensions to scan for usages + # Valid file extensions to scan for definitions and usages self.scan_extensions = {'.yml', '.j2'} - # Regexes - # Match simple Jinja variable usage: {{ var }} or with filters {{ var|filter }} + # Regex patterns self.simple_var_pattern = re.compile(r"{{\s*([a-zA-Z_]\w*)\s*(?:\|[^}]*)?}}") - # Match Jinja2 set definitions: {% set var = ... %} self.jinja_set_def = re.compile(r'{%\s*set\s+([a-zA-Z_]\w*)\s*=') - # Match Jinja2 for-loop variable definitions self.jinja_for_def = re.compile(r'{%\s*for\s+([a-zA-Z_]\w*)(?:\s*,\s*([a-zA-Z_]\w*))?\s+in') - # Match Ansible set_fact mapping start self.ansible_set_fact = re.compile(r'^(?:\s*[-]\s*)?set_fact\s*:\s*$') - # Match ansible vars block start self.ansible_vars_block = re.compile(r'^(?:\s*[-]\s*)?vars\s*:\s*$') - # Match ansible loop_control loop_var definition self.ansible_loop_var = re.compile(r'^\s*loop_var\s*:\s*([a-zA-Z_]\w*)') - # Match keys under a mapping block self.mapping_key = re.compile(r'^\s*([a-zA-Z_]\w*)\s*:\s*') - # Build initial defined-vars set from all var definition files + # Initialize defined set from var files self.defined = set() for vf in self.var_files: try: @@ -46,10 +39,7 @@ class TestVariableDefinitions(unittest.TestCase): except Exception: pass - def test_all_used_vars_are_defined(self): - undefined_uses = [] - - # Walk all .yml and .j2 files + # Phase 1: scan all files to collect inline definitions for root, _, files in os.walk(self.project_root): for fn in files: ext = os.path.splitext(fn)[1] @@ -58,74 +48,77 @@ class TestVariableDefinitions(unittest.TestCase): path = os.path.join(root, fn) in_set_fact = False - set_fact_indent = None + set_fact_indent = 0 in_vars_block = False - vars_block_indent = None - + vars_block_indent = 0 with open(path, 'r', encoding='utf-8', errors='ignore') as f: - for lineno, line in enumerate(f, 1): + for line in f: stripped = line.lstrip() indent = len(line) - len(stripped) - # Detect start of a set_fact mapping + # set_fact keys if self.ansible_set_fact.match(stripped): in_set_fact = True set_fact_indent = indent continue - # Inside set_fact, collect keys if in_set_fact: - if indent > set_fact_indent and self.mapping_key.match(stripped): - key = self.mapping_key.match(stripped).group(1) - self.defined.add(key) + if indent > set_fact_indent: + m = self.mapping_key.match(stripped) + if m: + self.defined.add(m.group(1)) continue else: in_set_fact = False - - # Detect start of a vars block under a task or play + # vars block keys if self.ansible_vars_block.match(stripped): in_vars_block = True vars_block_indent = indent continue - # Inside vars block, collect keys and skip usage scanning if in_vars_block: if indent > vars_block_indent: - # any mapping key under vars is a definition m = self.mapping_key.match(stripped) if m: - key = m.group(1) - self.defined.add(key) + self.defined.add(m.group(1)) continue else: in_vars_block = False - - # Detect loop_control loop_var definitions + # loop_var m_loop = self.ansible_loop_var.match(stripped) if m_loop: self.defined.add(m_loop.group(1)) - - # collect any {% set foo = ... %} definitions + # jinja set for m in self.jinja_set_def.finditer(line): self.defined.add(m.group(1)) - # collect any {% for var1, var2 in ... %} definitions + # jinja for for m in self.jinja_for_def.finditer(line): self.defined.add(m.group(1)) if m.group(2): self.defined.add(m.group(2)) - # collect simple usages only + def test_all_used_vars_are_defined(self): + undefined_uses = [] + # Phase 2: scan all files for usages + for root, _, files in os.walk(self.project_root): + for fn in files: + ext = os.path.splitext(fn)[1] + if ext not in self.scan_extensions: + continue + path = os.path.join(root, fn) + with open(path, 'r', encoding='utf-8', errors='ignore') as f: + for lineno, line in enumerate(f, 1): for m in self.simple_var_pattern.finditer(line): var = m.group(1) - # skip known Jinja builtins and whitelisted names - if var in ('lookup', 'role_name', 'domains', 'item', 'host_type', 'inventory_hostname', 'role_path', 'playbook_dir', 'ansible_become_password', 'inventory_dir'): + # skip builtins and whitelisted names + if var in ('lookup', 'role_name', 'domains', 'item', 'host_type', + 'inventory_hostname', 'role_path', 'playbook_dir', + 'ansible_become_password', 'inventory_dir'): continue - if var not in self.defined: - # check fallback names defaults_var or default_var - if (f"defaults_{var}" in self.defined or - f"default_{var}" in self.defined): - continue + # skip defaults_var fallback + if var not in self.defined and \ + f"defaults_{var}" not in self.defined and \ + f"default_{var}" not in self.defined: undefined_uses.append( f"{path}:{lineno}: '{{{{ {var} }}}}' used but not defined" ) - if undefined_uses: self.fail( "Undefined Jinja2 variables found (no fallback 'default_' or 'defaults_' key):\n" +