| #!/usr/bin/env python3 | |
| # Copyright (C) 2022 The Android Open Source Project | |
| # | |
| # Licensed under the Apache License, Version 2.0 (the "License"); | |
| # you may not use this file except in compliance with the License. | |
| # You may obtain a copy of the License at | |
| # | |
| # http://www.apache.org/licenses/LICENSE-2.0 | |
| # | |
| # Unless required by applicable law or agreed to in writing, software | |
| # distributed under the License is distributed on an "AS IS" BASIS, | |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| # See the License for the specific language governing permissions and | |
| # limitations under the License. | |
| """ | |
| Check Perfetto stdlib modules for banned patterns and documentation issues. | |
| This tool validates Perfetto SQL standard library modules for: | |
| - Banned SQL patterns (CREATE TABLE AS, CREATE VIEW AS, DROP statements, etc.) | |
| - Documentation completeness for public artifacts | |
| - Proper dependency declarations via INCLUDE PERFETTO MODULE (with --check-includes) | |
| """ | |
| import argparse | |
| import os | |
| import re | |
| import sys | |
| from collections import defaultdict | |
| from dataclasses import dataclass, field | |
| from typing import List, Set, Dict, Tuple | |
| ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | |
| sys.path.append(os.path.join(ROOT_DIR)) | |
| from python.generators.sql_processing.stdlib_parser import parse_all_modules, format_entities | |
| from python.generators.sql_processing.utils import check_banned_patterns | |
| from python.generators.sql_processing.utils import is_internal | |
| from python.generators.sql_processing.stdlib_tags import MODULE_TAGS, VALID_TAGS | |
| # Package name constants | |
| PKG_PRELUDE = "prelude" | |
| # Frozen set of allowed top-level public packages. | |
| # | |
| # This list is intentionally frozen to avoid namespace clashes with server | |
| # extensions which will increasingly add their own top-level packages. | |
| # | |
| # Historically, core trace processor functionality (e.g. intervals, graphs, | |
| # counters, slices) should have been placed under a 'std' top-level package, | |
| # but migration would be too disruptive. Instead, we freeze the current set | |
| # and require future core packages to be placed under 'std'. | |
| # | |
| # Platform-specific packages (e.g. 'android') may still be added as exceptions | |
| # since they are "closer to the platform" than core to Perfetto. Contact | |
| # lalitm@ for such exceptions. | |
| ALLOWED_TOPLEVEL_PUBLIC_PACKAGES = frozenset({ | |
| "android", | |
| "appleos", | |
| "chrome", | |
| "counters", | |
| "export", | |
| "graphs", | |
| "intervals", | |
| "linux", | |
| "pixel", | |
| "pkvm", | |
| "prelude", | |
| "sched", | |
| "slices", | |
| "stacks", | |
| "std", | |
| "time", | |
| "traced", | |
| "v8", | |
| "wattson", | |
| }) | |
| @dataclass | |
| class ModuleInfo: | |
| """Information about a module for include checking.""" | |
| name: str | |
| includes: List[str] = field(default_factory=list) | |
| entities: List[Dict] = field(default_factory=list) | |
| prelude_imports: List[str] = field(default_factory=list) | |
| silent_imports: List[str] = field(default_factory=list) | |
| unused_imports: List[str] = field(default_factory=list) | |
| used_entities_by_include: Dict[str, Set[str]] = field(default_factory=dict) | |
| def extract_referenced_entities(sql_file: str) -> Set[str]: | |
| """Extract all entity references from a SQL file. | |
| This uses regex-based parsing which has limitations: | |
| - May miss some references in complex SQL | |
| - May produce false positives in edge cases | |
| - Does not handle all SQL comment styles perfectly | |
| Returns: | |
| Set of entity names referenced in the file, or empty set on error. | |
| """ | |
| # Common SQL built-in functions that should be filtered out | |
| SQL_BUILTINS = { | |
| 'COUNT', 'SUM', 'AVG', 'MAX', 'MIN', 'ABS', 'CAST', 'COALESCE', 'IFNULL', | |
| 'NULLIF', 'SUBSTR', 'LENGTH', 'UPPER', 'LOWER', 'TRIM', 'REPLACE', | |
| 'ROUND', 'FLOOR', 'CEIL', 'DATETIME', 'DATE', 'TIME', 'STRFTIME', | |
| 'JULIANDAY', 'LAG', 'LEAD', 'ROW_NUMBER', 'RANK', 'DENSE_RANK', 'NTILE', | |
| 'FIRST_VALUE', 'LAST_VALUE', 'NTH_VALUE', 'GROUP_CONCAT', 'JSON_EXTRACT', | |
| 'JSON_ARRAY', 'JSON_OBJECT', 'PRINTF', 'CHAR', 'UNICODE', 'QUOTE', | |
| 'RANDOMBLOB', 'ZEROBLOB', 'HEX', 'UNHEX', 'TYPEOF', 'SQLITE_VERSION', | |
| 'TOTAL_CHANGES', 'CHANGES', 'LAST_INSERT_ROWID' | |
| } | |
| # Perfetto SQL type names and common SQL keywords to filter out | |
| SQL_TYPES_AND_KEYWORDS = { | |
| # Perfetto SQL types | |
| 'TABLEORSUBQUERY', | |
| 'COLUMNNAME', | |
| 'EXPR', | |
| 'STRING', | |
| 'LONG', | |
| 'INT', | |
| 'BOOL', | |
| 'DOUBLE', | |
| 'TIMESTAMP', | |
| 'DURATION', | |
| 'ARGSETID', | |
| 'ID', | |
| 'JOINID', | |
| # SQL keywords | |
| 'SELECT', | |
| 'FROM', | |
| 'WHERE', | |
| 'JOIN', | |
| 'LEFT', | |
| 'RIGHT', | |
| 'INNER', | |
| 'OUTER', | |
| 'ON', | |
| 'USING', | |
| 'GROUP', | |
| 'BY', | |
| 'ORDER', | |
| 'HAVING', | |
| 'LIMIT', | |
| 'OFFSET', | |
| 'AS', | |
| 'ASC', | |
| 'DESC', | |
| 'AND', | |
| 'OR', | |
| 'NOT', | |
| 'IN', | |
| 'IS', | |
| 'NULL', | |
| 'TRUE', | |
| 'FALSE', | |
| 'CASE', | |
| 'WHEN', | |
| 'THEN', | |
| 'ELSE', | |
| 'END', | |
| 'WITH', | |
| 'PARTITION', | |
| 'PARTITIONED', | |
| 'OVER', | |
| 'RETURNS', | |
| 'CREATE', | |
| 'DROP', | |
| 'INSERT', | |
| 'UPDATE', | |
| 'DELETE', | |
| 'CROSS' | |
| } | |
| references = set() | |
| try: | |
| with open(sql_file, 'r', encoding='utf-8') as f: | |
| content = f.read() | |
| # Remove both single-line (--) and multi-line (/* */) comments | |
| # First, remove multi-line comments | |
| content = re.sub(r'/\*.*?\*/', '', content, flags=re.DOTALL) | |
| # Then remove single-line comments while preserving string literals | |
| lines = [] | |
| for line in content.split('\n'): | |
| comment_pos = -1 | |
| in_string = False | |
| string_char = None | |
| for i, char in enumerate(line): | |
| if char in ('"', "'") and (i == 0 or line[i - 1] != '\\'): | |
| if not in_string: | |
| in_string = True | |
| string_char = char | |
| elif char == string_char: | |
| in_string = False | |
| string_char = None | |
| elif char == '-' and i < len(line) - 1 and line[ | |
| i + 1] == '-' and not in_string: | |
| comment_pos = i | |
| break | |
| if comment_pos >= 0: | |
| line = line[:comment_pos] | |
| lines.append(line) | |
| content = '\n'.join(lines) | |
| # Extract entity references (tables, views, functions, macros) | |
| # Look for identifiers that are likely entities | |
| # Handle SPAN_JOIN and SPAN_OUTER_JOIN specially (extracts multiple tables) | |
| span_join_pattern = r'\bSPAN_(?:OUTER_)?JOIN\s*\(([^)]+)\)' | |
| for match in re.finditer(span_join_pattern, content, re.IGNORECASE): | |
| span_content = match.group(1) | |
| # Extract table names (identifiers before PARTITIONED, before comma, or at end) | |
| # Pattern: identifier followed by PARTITIONED, comma, or end of content | |
| table_pattern = r'\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?:PARTITIONED|,|$)' | |
| for table_match in re.finditer(table_pattern, span_content, | |
| re.IGNORECASE): | |
| entity = table_match.group(1) | |
| # Filter out keywords like PARTITIONED itself | |
| if entity.upper() not in SQL_BUILTINS and entity.upper( | |
| ) != 'PARTITIONED': | |
| references.add(entity) | |
| # Other patterns | |
| patterns = [ | |
| r'\bFROM\s+([a-zA-Z_][a-zA-Z0-9_]*)\b', | |
| r'\bJOIN\s+([a-zA-Z_][a-zA-Z0-9_]*)\b', | |
| r'\bIN\s+([a-zA-Z_][a-zA-Z0-9_]*)\b', # IN table_name or NOT IN table_name | |
| r'\b([a-zA-Z_][a-zA-Z0-9_!]*)\s*\(', | |
| ] | |
| for pattern in patterns: | |
| matches = re.finditer(pattern, content, re.IGNORECASE) | |
| for match in matches: | |
| entity = match.group(1) | |
| # Filter out SQL built-in functions | |
| if entity.upper() not in SQL_BUILTINS: | |
| # Strip trailing '!' from macro invocations (macros are defined without '!' but invoked with it) | |
| entity_normalized = entity.rstrip('!') | |
| references.add(entity_normalized) | |
| # Extract table arguments from macro calls | |
| # Pattern: macro_name!(arg1, arg2, ...) where args could be table references | |
| macro_call_pattern = r'\b([a-zA-Z_][a-zA-Z0-9_]*!)\s*\(' | |
| for match in re.finditer(macro_call_pattern, content): | |
| # Find matching closing paren | |
| start_pos = match.end() | |
| paren_depth = 1 | |
| pos = start_pos | |
| while pos < len(content) and paren_depth > 0: | |
| if content[pos] == '(': | |
| paren_depth += 1 | |
| elif content[pos] == ')': | |
| paren_depth -= 1 | |
| pos += 1 | |
| if paren_depth == 0: | |
| args_str = content[start_pos:pos - 1] | |
| # Split by commas (simple split, doesn't handle nested parens perfectly) | |
| # Extract simple identifiers that could be table references | |
| for arg in args_str.split(','): | |
| arg = arg.strip() | |
| # Match simple identifier: not starting with $, not containing dots or SELECT | |
| if re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', arg): | |
| # Filter out keywords and types | |
| if (arg.upper() not in SQL_BUILTINS and | |
| arg.upper() not in SQL_TYPES_AND_KEYWORDS): | |
| references.add(arg) | |
| except IOError as e: | |
| sys.stderr.write(f"Warning: Failed to read {sql_file}: {e}\n") | |
| return set() | |
| except Exception as e: | |
| sys.stderr.write( | |
| f"Warning: Failed to extract entities from {sql_file}: {e}\n") | |
| return set() | |
| return references | |
| def check_includes(modules: List[Tuple], quiet: bool = False) -> int: | |
| """Check that modules properly declare their dependencies. | |
| Args: | |
| modules: List of tuples (abs_path, rel_path, module_name, parsed_module) | |
| quiet: If True, suppress detailed output | |
| Returns: | |
| Number of modules with include errors. | |
| """ | |
| # Get entity information | |
| entities_data = format_entities(modules) | |
| modules_by_name = {} | |
| entity_to_module = entities_data['entity_to_module'] | |
| # Build modules_by_name from the parsed data | |
| for module_name, module_data in entities_data['modules'].items(): | |
| module_info = ModuleInfo( | |
| name=module_name, | |
| includes=module_data['includes'], | |
| entities=module_data['entities']) | |
| modules_by_name[module_name] = module_info | |
| # Build modules_by_file for reference analysis | |
| modules_by_file = {} | |
| for abs_path, _, module_name, _ in modules: | |
| modules_by_file[abs_path] = modules_by_name.get(module_name) | |
| # ANALYSIS PASS: Analyze usages and compute dependencies | |
| for abs_path, module_info in modules_by_file.items(): | |
| if not module_info: | |
| continue | |
| # Extract all entity references in this module | |
| references = extract_referenced_entities(abs_path) | |
| # Get entities defined in this module (skip self-references) | |
| # Extract just the names from the entity dicts | |
| own_entities = {e['name'] for e in module_info.entities} | |
| # Build map of included module -> entities it provides | |
| included_entities_by_module = {} | |
| for included_module_name in module_info.includes: | |
| if included_module_name in modules_by_name: | |
| # Extract all entity names (both public and internal) | |
| # Using internal entities from an included module is allowed | |
| all_entities = { | |
| e['name'] for e in modules_by_name[included_module_name].entities | |
| } | |
| included_entities_by_module[included_module_name] = all_entities | |
| # Track which entities from each include are actually used | |
| used_entities_by_include = defaultdict(set) | |
| # Track imports that are used but not declared | |
| prelude_imports = [] | |
| silent_imports = [] | |
| # Analyze each reference | |
| for ref in references: | |
| # Skip self-references | |
| if ref in own_entities: | |
| continue | |
| # Check if from an explicitly included module | |
| is_from_included_module = False | |
| for included_module_name, entities in included_entities_by_module.items(): | |
| if ref in entities: | |
| used_entities_by_include[included_module_name].add(ref) | |
| is_from_included_module = True | |
| break | |
| if is_from_included_module: | |
| continue | |
| # Find which module defines this entity | |
| defining_module = entity_to_module.get(ref) | |
| if not defining_module: | |
| continue | |
| # It's an implicit import | |
| if defining_module.startswith(f'{PKG_PRELUDE}.'): | |
| prelude_imports.append(f"{ref} (from {defining_module})") | |
| else: | |
| silent_imports.append(f"{ref} (from {defining_module})") | |
| # Find unused imports | |
| unused_imports = [ | |
| inc for inc in module_info.includes | |
| if inc not in used_entities_by_include | |
| ] | |
| # Store results | |
| module_info.prelude_imports = sorted(list(set(prelude_imports))) | |
| module_info.silent_imports = sorted(list(set(silent_imports))) | |
| module_info.unused_imports = sorted(unused_imports) | |
| module_info.used_entities_by_include = { | |
| inc: sorted(list(entities)) | |
| for inc, entities in used_entities_by_include.items() | |
| } | |
| # Count modules with errors (excluding prelude modules) | |
| # Prelude modules are exempt because they provide core functionality | |
| # that is automatically available to all modules | |
| modules_with_errors = [] | |
| for module_name in sorted(modules_by_name.keys()): | |
| module = modules_by_name[module_name] | |
| if module_name.startswith(f'{PKG_PRELUDE}.'): | |
| continue | |
| if module.unused_imports or module.silent_imports: | |
| modules_with_errors.append(module_name) | |
| if not quiet: | |
| if modules_with_errors: | |
| print( | |
| f"\nFound {len(modules_with_errors)} module(s) with include errors:\n" | |
| ) | |
| else: | |
| print(f"\nNo include errors found in any modules!") | |
| return 0 | |
| for module_name in sorted(modules_by_name.keys()): | |
| module = modules_by_name[module_name] | |
| # Skip prelude modules | |
| if module_name.startswith(f'{PKG_PRELUDE}.'): | |
| continue | |
| # Only show modules with errors | |
| if not (module.unused_imports or module.silent_imports): | |
| continue | |
| print(f"Module: {module.name}") | |
| if module.unused_imports: | |
| print(f" Unused imports ({len(module.unused_imports)}):") | |
| for imp in sorted(module.unused_imports): | |
| print(f" - {imp}") | |
| if module.silent_imports: | |
| print(f" Silent imports ({len(module.silent_imports)}):") | |
| for imp in sorted(module.silent_imports): | |
| print(f" - {imp}") | |
| print() | |
| return len(modules_with_errors) | |
| def has_public_artifacts(parsed) -> bool: | |
| """Check if a parsed module has any public artifacts. | |
| Args: | |
| parsed: Parsed module object | |
| Returns: | |
| True if the module has any public tables, views, functions, table functions, | |
| or macros (artifacts not prefixed with '_'). | |
| """ | |
| # Check tables/views | |
| for table in parsed.table_views: | |
| if not is_internal(table.name): | |
| return True | |
| # Check functions | |
| for func in parsed.functions: | |
| if not is_internal(func.name): | |
| return True | |
| # Check table functions | |
| for func in parsed.table_functions: | |
| if not is_internal(func.name): | |
| return True | |
| # Check macros | |
| for macro in parsed.macros: | |
| if not is_internal(macro.name): | |
| return True | |
| return False | |
| def check_tags(modules: List[Tuple], quiet: bool = False) -> int: | |
| """Check that all modules with public artifacts have tags defined. | |
| Args: | |
| modules: List of tuples (abs_path, rel_path, module_name, parsed_module) | |
| quiet: If True, suppress detailed output | |
| Returns: | |
| Number of modules missing tags. | |
| """ | |
| modules_missing_tags = [] | |
| for _, _, module_name, parsed in modules: | |
| # If module has public artifacts, it must have tags | |
| if has_public_artifacts(parsed): | |
| tags = MODULE_TAGS.get(module_name, []) | |
| if not tags: | |
| modules_missing_tags.append(module_name) | |
| if not quiet: | |
| if modules_missing_tags: | |
| print( | |
| f"\nFound {len(modules_missing_tags)} module(s) with public artifacts but missing tags:\n" | |
| ) | |
| for module_name in sorted(modules_missing_tags): | |
| print(f" - {module_name}") | |
| print( | |
| f"\nPlease add tags for these modules in python/generators/sql_processing/stdlib_tags.py" | |
| ) | |
| else: | |
| print(f"\nAll modules with public artifacts have tags defined!") | |
| return len(modules_missing_tags) | |
| def check_orphaned_tags(modules: List[Tuple], quiet: bool = False) -> int: | |
| """Check that all tags in MODULE_TAGS correspond to actual modules. | |
| Args: | |
| modules: List of tuples (abs_path, rel_path, module_name, parsed_module) | |
| quiet: If True, suppress detailed output | |
| Returns: | |
| Number of orphaned tags (tags for non-existent modules). | |
| """ | |
| # Build set of actual module names | |
| actual_modules = set() | |
| for _, _, module_name, _ in modules: | |
| actual_modules.add(module_name) | |
| # Find tags for modules that don't exist | |
| orphaned_tags = [] | |
| for tagged_module in MODULE_TAGS.keys(): | |
| if tagged_module not in actual_modules: | |
| orphaned_tags.append(tagged_module) | |
| if not quiet: | |
| if orphaned_tags: | |
| print(f"\nFound {len(orphaned_tags)} tag(s) for non-existent modules:\n") | |
| for module_name in sorted(orphaned_tags): | |
| print(f" - {module_name}") | |
| print( | |
| f"\nPlease remove these from python/generators/sql_processing/stdlib_tags.py" | |
| ) | |
| else: | |
| print(f"\nNo orphaned tags found!") | |
| return len(orphaned_tags) | |
| def check_invalid_tags(quiet: bool = False) -> int: | |
| """Check that all tags used in MODULE_TAGS are from VALID_TAGS. | |
| Args: | |
| quiet: If True, suppress detailed output | |
| Returns: | |
| Number of invalid tags found. | |
| """ | |
| invalid_tags_by_module = {} | |
| # Check each module's tags | |
| for module_name, tags in MODULE_TAGS.items(): | |
| invalid = [] | |
| for tag in tags: | |
| if tag not in VALID_TAGS: | |
| invalid.append(tag) | |
| if invalid: | |
| invalid_tags_by_module[module_name] = invalid | |
| if not quiet: | |
| if invalid_tags_by_module: | |
| total_invalid = sum(len(tags) for tags in invalid_tags_by_module.values()) | |
| print( | |
| f"\nFound {total_invalid} invalid tag(s) in {len(invalid_tags_by_module)} module(s):\n" | |
| ) | |
| for module_name in sorted(invalid_tags_by_module.keys()): | |
| print(f" {module_name}:") | |
| for tag in sorted(invalid_tags_by_module[module_name]): | |
| print(f" - {tag}") | |
| print(f"\nAll tags must be from VALID_TAGS in stdlib_tags.py") | |
| else: | |
| print(f"\nAll tags are valid!") | |
| return len(invalid_tags_by_module) | |
| def check_nested_tag_parents(quiet: bool = False) -> int: | |
| """Check that nested tags (with ':') have their parent tags present. | |
| Args: | |
| quiet: If True, suppress detailed output | |
| Returns: | |
| Number of modules with missing parent tags. | |
| """ | |
| missing_parent_tags_by_module = {} | |
| for module_name, tags in MODULE_TAGS.items(): | |
| tags_set = set(tags) | |
| missing_parents = [] | |
| for tag in tags: | |
| if ':' in tag: | |
| parent = tag.split(':')[0] | |
| if parent not in tags_set: | |
| missing_parents.append(f"{tag} (missing parent: {parent})") | |
| if missing_parents: | |
| missing_parent_tags_by_module[module_name] = missing_parents | |
| if not quiet: | |
| if missing_parent_tags_by_module: | |
| total_missing = sum( | |
| len(tags) for tags in missing_parent_tags_by_module.values()) | |
| print( | |
| f"\nFound {total_missing} nested tag(s) missing parent tags in {len(missing_parent_tags_by_module)} module(s):\n" | |
| ) | |
| for module_name in sorted(missing_parent_tags_by_module.keys()): | |
| print(f" {module_name}:") | |
| for tag_msg in sorted(missing_parent_tags_by_module[module_name]): | |
| print(f" - {tag_msg}") | |
| print( | |
| f"\nNested tags (e.g., 'power:battery') must include their parent tag (e.g., 'power')" | |
| ) | |
| else: | |
| print(f"\nAll nested tags have their parent tags!") | |
| return len(missing_parent_tags_by_module) | |
| def check_new_packages(modules: List[Tuple], quiet: bool = False) -> int: | |
| """Check that no new top-level public packages have been added. | |
| This check ensures that all top-level packages with public artifacts are | |
| in the allowed list. New packages should be placed under the 'std' folder. | |
| Args: | |
| modules: List of tuples (abs_path, rel_path, module_name, parsed_module) | |
| quiet: If True, suppress detailed output | |
| Returns: | |
| Number of disallowed new packages found. | |
| """ | |
| # Find all packages with public artifacts | |
| packages_with_public = set() | |
| for _, _, module_name, parsed in modules: | |
| if has_public_artifacts(parsed): | |
| # Extract top-level package name | |
| package = module_name.split('.')[0] | |
| packages_with_public.add(package) | |
| # Find packages not in the allowed list | |
| disallowed_packages = packages_with_public - ALLOWED_TOPLEVEL_PUBLIC_PACKAGES | |
| if not quiet: | |
| if disallowed_packages: | |
| print(f"\nFound {len(disallowed_packages)} disallowed new top-level " | |
| f"public package(s):\n") | |
| for pkg in sorted(disallowed_packages): | |
| print(f" - {pkg}") | |
| print( | |
| f"\nTop-level public packages are frozen to avoid namespace clashes " | |
| f"with server extensions.") | |
| print( | |
| f"\nCore trace processor packages (like intervals, graphs, counters) " | |
| f"should be placed under 'std' (e.g. std.{list(disallowed_packages)[0]})." | |
| ) | |
| print(f"\nPlatform-specific packages (like 'android') may be excepted. " | |
| f"Contact lalitm@ for exceptions.") | |
| else: | |
| print(f"\nNo disallowed new top-level public packages found!") | |
| return len(disallowed_packages) | |
| def main() -> int: | |
| parser = argparse.ArgumentParser( | |
| description="Check stdlib modules for banned patterns and documentation") | |
| parser.add_argument( | |
| '--stdlib-sources', | |
| default=os.path.join(ROOT_DIR, "src", "trace_processor", "perfetto_sql", | |
| "stdlib")) | |
| parser.add_argument( | |
| '--verbose', | |
| action='store_true', | |
| default=False, | |
| help='Enable additional logging') | |
| parser.add_argument( | |
| '--name-filter', | |
| default=None, | |
| type=str, | |
| help='Filter the name of the modules to check (regex syntax)') | |
| parser.add_argument( | |
| '--check-includes', | |
| action='store_true', | |
| default=False, | |
| help='Also check that modules properly declare their dependencies via INCLUDE statements' | |
| ) | |
| parser.add_argument( | |
| '--check-tags', | |
| action='store_true', | |
| default=False, | |
| help='Check that all modules with public artifacts have tags defined') | |
| parser.add_argument( | |
| '--check-orphaned-tags', | |
| action='store_true', | |
| default=False, | |
| help='Check that all tags in MODULE_TAGS correspond to actual modules') | |
| parser.add_argument( | |
| '--check-new-packages', | |
| action='store_true', | |
| default=False, | |
| help='Check that no new top-level public packages have been added') | |
| args = parser.parse_args() | |
| # Parse all modules once with internal artifacts included | |
| # We need internal artifacts for dependency checking, but will filter them | |
| # out when checking schemas (internal artifacts don't need schemas) | |
| modules = parse_all_modules( | |
| stdlib_path=args.stdlib_sources, | |
| include_internal=True, | |
| name_filter=args.name_filter) | |
| if args.verbose: | |
| for abs_path, rel_path, _, parsed in modules: | |
| obj_count = ( | |
| len(parsed.functions) + len(parsed.table_functions) + | |
| len(parsed.table_views) + len(parsed.macros)) | |
| print(f"Parsing '{rel_path}' ({obj_count} objects, " | |
| f"{len(parsed.errors)} errors) - " | |
| f"{len(parsed.functions)} functions, " | |
| f"{len(parsed.table_functions)} table functions, " | |
| f"{len(parsed.table_views)} tables/views, " | |
| f"{len(parsed.macros)} macros.") | |
| all_errors = 0 | |
| for abs_path, rel_path, _, parsed in modules: | |
| errors = [] | |
| # Read SQL content | |
| with open(abs_path, 'r', encoding='utf-8') as f: | |
| sql = f.read() | |
| errors += check_banned_patterns(parsed, sql) | |
| if errors: | |
| sys.stderr.write(f"\nFound {len(errors)} errors in file " | |
| f"'{os.path.normpath(abs_path)}':\n- ") | |
| sys.stderr.write("\n- ".join(errors)) | |
| sys.stderr.write("\n\n") | |
| all_errors += len(errors) | |
| # Check includes if requested | |
| include_errors = 0 | |
| if args.check_includes: | |
| include_errors = check_includes(modules, quiet=not args.verbose) | |
| # Check tags if requested | |
| tag_errors = 0 | |
| invalid_tag_errors = 0 | |
| nested_tag_errors = 0 | |
| if args.check_tags: | |
| # Always check for invalid tags and nested tag parents when checking tags | |
| invalid_tag_errors = check_invalid_tags(quiet=not args.verbose) | |
| nested_tag_errors = check_nested_tag_parents(quiet=not args.verbose) | |
| tag_errors = check_tags(modules, quiet=not args.verbose) | |
| # Check orphaned tags if requested | |
| orphaned_tag_errors = 0 | |
| if args.check_orphaned_tags: | |
| orphaned_tag_errors = check_orphaned_tags(modules, quiet=not args.verbose) | |
| # Check for new top-level public packages if requested | |
| new_package_errors = 0 | |
| if args.check_new_packages: | |
| new_package_errors = check_new_packages(modules, quiet=not args.verbose) | |
| total_errors = all_errors + include_errors + tag_errors + orphaned_tag_errors + invalid_tag_errors + nested_tag_errors + new_package_errors | |
| return 0 if not total_errors else 1 | |
| if __name__ == "__main__": | |
| sys.exit(main()) |