├── .gitignore ├── LICENSE.txt ├── MANIFEST.in ├── README.md ├── docs ├── Class_Diagram.dia ├── Class_Diagram_1.png └── Class_Diagram_2.png ├── pscodeanalyzer ├── __init__.py ├── engine.py └── rules │ ├── __init__.py │ └── peoplecode.py ├── setup.cfg ├── setup.py ├── tests ├── HRMH_SETUP.HRMHServices.ppl ├── PTPG_WORKREC.FUNCLIB.FieldFormula.ppl ├── plain_text_sample.txt ├── samplerules │ ├── __init__.py │ └── model.py ├── settings.json ├── test_peoplecode.py ├── test_plain_text.py └── variable_names.ppl └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | pscodeanalyzer-*/ 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # IPython Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # dotenv 80 | .env 81 | 82 | # virtualenv 83 | .venv/ 84 | venv/ 85 | ENV/ 86 | 87 | # Spyder project settings 88 | .spyderproject 89 | 90 | # Rope project settings 91 | .ropeproject 92 | 93 | # Dia backup files 94 | *.dia~ 95 | *.dia.autosave 96 | docs/__dia* 97 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Leandro Baca 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | # Include the README 2 | include *.md 3 | 4 | # Include the license file 5 | include LICENSE.txt 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Static Code Analyzer 2 | 3 | **A rule-based static code analyzer with PeopleCode-specific features and an extensible plug-in architecture.** 4 | 5 | ## About 6 | 7 | Although it was written with PeopleCode validation in mind, it should be noted that it is in fact a configurable code analysis engine that can be used to evaluate any text-based file and produce reports based on its findings. 8 | 9 | ## Installation 10 | 11 | To install the Static Code Analyzer, run the following: 12 | 13 | ```bash 14 | pip install pscodeanalyzer 15 | ``` 16 | 17 | ## Usage 18 | 19 | Refer to the [wiki](https://github.com/lbaca/PSCodeAnalyzer/wiki) for details about the design, architecture, and configuration of the Static Code Analyzer, as well as instructions on how to invoke it and extend it with custom plug-ins. 20 | 21 | ## Acknowledgements 22 | 23 | The Static Code Analyzer was written as part of the deliverables for my Master of Science dissertation at the University of Liverpool, titled "A Framework for Customizing ERP Systems to Increase Software Reuse and Reduce Rework When Challenged with Evolving Requirements." I mention this primarily in gratitude to my employer, who graciously waived their claim to intellectual property on my work as part of this academic pursuit. 24 | -------------------------------------------------------------------------------- /docs/Class_Diagram.dia: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lbaca/PSCodeAnalyzer/d917af3f8e77aa5402d6aa12489615836eddc864/docs/Class_Diagram.dia -------------------------------------------------------------------------------- /docs/Class_Diagram_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lbaca/PSCodeAnalyzer/d917af3f8e77aa5402d6aa12489615836eddc864/docs/Class_Diagram_1.png -------------------------------------------------------------------------------- /docs/Class_Diagram_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lbaca/PSCodeAnalyzer/d917af3f8e77aa5402d6aa12489615836eddc864/docs/Class_Diagram_2.png -------------------------------------------------------------------------------- /pscodeanalyzer/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | -------------------------------------------------------------------------------- /pscodeanalyzer/engine.py: -------------------------------------------------------------------------------- 1 | """Static code analyzer engine with configurable plug-in rules.""" 2 | 3 | import argparse 4 | import glob 5 | import importlib 6 | import json 7 | import logging 8 | import mmap 9 | import os.path 10 | import platform 11 | import re 12 | import sys 13 | from abc import ABC, abstractmethod 14 | from collections import namedtuple 15 | from collections.abc import Iterable 16 | from enum import Enum 17 | 18 | 19 | # GLOBAL VARIABLES 20 | _verbose = False 21 | _logger = logging.getLogger('engine') 22 | _config_evaluators = None 23 | default_config_file_name = 'settings.json' 24 | 25 | 26 | # MODEL 27 | Interval = namedtuple('Interval', ['start', 'end']) 28 | 29 | 30 | class ReportType(Enum): 31 | """Enumeration of report types.""" 32 | 33 | ERROR = 'E' 34 | WARNING = 'W' 35 | INFO = 'N' 36 | 37 | 38 | class FileReports: 39 | """Pointer to a file path and its analysis reports.""" 40 | 41 | def __init__(self, file_path, source_type=None, reports=[]): 42 | """Create a FileReports object.""" 43 | self.file_path = file_path 44 | self.source_type = source_type 45 | self.reports = reports 46 | 47 | def __str__(self): 48 | """Return a string representation of the object.""" 49 | out = self.basename 50 | if self.source_type is not None: 51 | out += f' ({str(self.source_type)})' 52 | if self.reports: 53 | out += f': {self.cumulative_status.name}' 54 | for r in self.reports: 55 | out += f'\n - {str(r)}' 56 | else: 57 | out += ': no reports' 58 | return out 59 | 60 | @property 61 | def basename(self): 62 | """Return the file name from the path.""" 63 | return os.path.basename(self.file_path) 64 | 65 | @property 66 | def cumulative_status(self): 67 | """Return the cumulative status of the reports.""" 68 | status = ReportType.INFO 69 | for r in self.reports: 70 | if r.is_error: 71 | status = ReportType.ERROR 72 | break 73 | elif r.is_warning: 74 | status = ReportType.WARNING 75 | return status 76 | 77 | @property 78 | def is_error(self): 79 | """Return whether any reports are in error status.""" 80 | return (self.cumulative_status == ReportType.ERROR) 81 | 82 | 83 | class Report: 84 | """A report about a rule with regard to a source file. 85 | 86 | - rule_code is the code of the rule which produced the report (can 87 | be None for special cases, such as parsers that wish to report 88 | parsing errors). 89 | - message is the one-line text to summarize the report. 90 | - report_type must be one of the ReportType enumeration values. 91 | - line and column indicate where in the source file the code of 92 | interest can be found. 93 | - text is the code of interest, which could span multiple lines. 94 | - detail is a more descriptive message regarding the report. 95 | """ 96 | 97 | def __init__(self, rule_code, message, report_type=ReportType.ERROR, 98 | line=None, column=None, text=None, detail=None): 99 | """Create a report.""" 100 | self.rule_code = rule_code 101 | self.message = message 102 | self.type = report_type 103 | self.line = line 104 | self.column = column 105 | self.text = text 106 | self.detail = detail 107 | 108 | def __str__(self): 109 | """Return a string representation of the report. 110 | 111 | Typically used for summaries. 112 | """ 113 | if self.rule_code is None: 114 | out = f'[{self.type.name}]' 115 | elif type(self.rule_code) is int: 116 | out = f'[{self.type.value}{self.rule_code:04d}]' 117 | else: 118 | out = f'[{self.type.name}:{self.rule_code}]' 119 | 120 | if self.is_error: 121 | out += ' Error' 122 | elif self.is_warning: 123 | out += ' Warning' 124 | elif self.is_info: 125 | out += ' Information' 126 | 127 | if self.line is None: 128 | if self.column is not None: 129 | out += f' at position {self.column}' 130 | else: 131 | out += f' on line {self.line}' 132 | if self.column is not None: 133 | out += f', column {self.column}' 134 | 135 | if self.message: 136 | out += f': {self.message}' 137 | 138 | return out 139 | 140 | @property 141 | def is_error(self): 142 | """Return True if the report is an error.""" 143 | return (self.type == ReportType.ERROR) 144 | 145 | @property 146 | def is_warning(self): 147 | """Return True if the report is a warning.""" 148 | return (self.type == ReportType.WARNING) 149 | 150 | @property 151 | def is_info(self): 152 | """Return True if the report is informational.""" 153 | return (self.type == ReportType.INFO) 154 | 155 | 156 | class Evaluator(ABC): 157 | """Abstract base class for all evaluators (rules and proxies). 158 | 159 | The following configuration options apply: 160 | - "class": the name of the class to instantiate (should be "Rule", 161 | "RegexRule", "Proxy", or a fully-qualified subclass of any of 162 | these) 163 | - "description": used as the description for the evaluator instance 164 | (optional) 165 | """ 166 | 167 | def __init__(self, config): 168 | """Construct an evaluator with the given configuration.""" 169 | self.config = config 170 | self.description = config.get('description') 171 | self.intervals = [] 172 | _logger.debug(f'Instantiated {self.__class__.__name__}') 173 | 174 | @abstractmethod 175 | def reset(self): 176 | """Reset the evaluator for a new evaluation. 177 | 178 | To be implemented by subclasses, usually when proxied. Should 179 | not be called from the constructor because subclasses may wish 180 | to reset members that are not yet defined. 181 | """ 182 | pass 183 | 184 | def add_interval_str(self, interval): 185 | """Add an interval for evaluator applicability from a string. 186 | 187 | The interval value can be one of the following: 188 | 189 | - m-n: results in an interval between m and n 190 | - m- : results in an interval between m and the end of the input 191 | - m : results in an interval only for m (same as m-m) 192 | """ 193 | if interval: 194 | from_location = None 195 | to_location = None 196 | try: 197 | if '-' in interval: 198 | parts = interval.split('-', maxsplit=1) 199 | if 1 <= len(parts) <= 2: 200 | if len(parts[0] > 0): 201 | from_location = int(parts[0]) 202 | if len(parts) == 2: 203 | if len(parts[1] > 0): 204 | to_location = int(parts[1]) 205 | else: 206 | from_location = int(interval) 207 | if from_location: 208 | self.add_interval(from_location, to_location) 209 | else: 210 | _logger.warning(f'Invalid interval: "{interval}"') 211 | except ValueError: 212 | _logger.warning(f'Invalid interval: "{interval}"') 213 | 214 | def add_interval(self, from_location, to_location): 215 | """Add an interval for evaluator applicability. 216 | 217 | The interval would typically refer to line numbers, but 218 | subclasses can override as required. 219 | 220 | If to_location is None, the interval is until the end of the 221 | input. Overlaps are not resolved in any way. 222 | """ 223 | if (from_location 224 | and (to_location is None 225 | or from_location <= to_location)): 226 | self.intervals.append(Interval(from_location, to_location)) 227 | else: 228 | _logger.warning('Invalid interval') 229 | 230 | def is_position_in_intervals(self, position_start, position_end=None): 231 | """Determine if the given position is within targeted intervals. 232 | 233 | Returns True if position is included within any of the 234 | configured intervals, or if no intervals have been configured. 235 | """ 236 | if self.intervals: 237 | end = position_start if position_end is None else position_end 238 | for interval in self.intervals: 239 | compare_end = end if interval.end is None else interval.end 240 | if position_start <= compare_end and end >= interval.start: 241 | return True 242 | else: 243 | return False 244 | else: 245 | return True 246 | 247 | @abstractmethod 248 | def evaluate(self): 249 | """Evaluate the evaluator, itself a Rule or a Proxy. 250 | 251 | Subclasses are free to define additional arguments for this 252 | method (e.g., Rules may want to interact with individual files, 253 | while Proxies may want to interact with pre-attached files). 254 | 255 | Returns a list of Report objects. 256 | """ 257 | pass 258 | 259 | @property 260 | @abstractmethod 261 | def is_proxy(self): 262 | """Return True if the evaluator is a proxy.""" 263 | pass 264 | 265 | @property 266 | @abstractmethod 267 | def rule_count(self): 268 | """Return the number of rules included in this evaluator.""" 269 | pass 270 | 271 | 272 | class Rule(Evaluator): 273 | """Abstract base class for code analyzer rules. 274 | 275 | The following configuration options apply: 276 | - "code": an integer between 1 and 9999 that uniquely identifies the 277 | rule 278 | - "description": additionally used as the default message for 279 | reports (optional, defaults to None) 280 | - "default_report_type": one of "ERROR" (default), "WARNING" or 281 | "INFO" (optional) 282 | - "include_source_types": a list of integers that identify the 283 | source types that this rule applies to; if empty (default), all 284 | source types apply (optional) 285 | - "exclude_source_types": a list of integers that identify the 286 | source types that this rule does not apply to; if a source type is 287 | an explicit member of both lists, it is excluded (optional) 288 | """ 289 | 290 | def __init__(self, config): 291 | """Construct a rule with the given configuration.""" 292 | super(Rule, self).__init__(config) 293 | self.code = int(config['code']) 294 | if self.code <= 0: 295 | raise ValueError('code must be a positive integer') 296 | default_report_type = config.get('default_report_type', 'ERROR') 297 | if hasattr(ReportType, default_report_type): 298 | self.default_report_type = getattr(ReportType, default_report_type) 299 | else: 300 | raise ValueError('invalid default_report_type: ' 301 | f'"{default_report_type}"') 302 | self.default_message = self.description 303 | self.include_source_types = config.get('include_source_types', []) 304 | self.exclude_source_types = config.get('exclude_source_types', []) 305 | 306 | def reset(self): 307 | """Reset the rule for a new evaluation.""" 308 | super(Rule, self).reset() 309 | self.intervals = [] 310 | 311 | def applies_to_source_type(self, source_type): 312 | """Return True if this Rule applies to the provided source type.""" 313 | applies = True 314 | if source_type is not None: 315 | if self.include_source_types: 316 | applies = (source_type in self.include_source_types) 317 | applies = (applies 318 | and (source_type not in self.exclude_source_types)) 319 | return applies 320 | 321 | def evaluate(self, source): 322 | """Evaluate the rule against the referenced source. 323 | 324 | source can be a path to a file or an open file-like object. 325 | Subclasses are free to treat source differently (e.g., a string 326 | with the code to analyze, a parse tree, etc.). 327 | 328 | Returns a list of Report objects. 329 | """ 330 | _logger.debug(f'Evaluating {self.__class__.__name__}') 331 | if source is None: 332 | raise ValueError(f'"{source}" cannot be None') 333 | if type(source) is str: 334 | if os.path.isfile(source): 335 | with open(source) as source_file: 336 | reports = self.evaluate_file(source_file) 337 | else: 338 | raise ValueError(f'"{source}" not found or not a file') 339 | else: 340 | reports = self.evaluate_file(source) 341 | return reports 342 | 343 | def evaluate_file(self, source_file): 344 | """Evaluate the rule against the provided file-like object. 345 | 346 | The file must already be open. 347 | 348 | Returns a list of Report objects. 349 | """ 350 | # Abstract class, do nothing; not identified as @abstractmethod 351 | # because subclasses are not required to implement this method 352 | # (e.g., when called through a Proxy) 353 | return [] 354 | 355 | @property 356 | def is_proxy(self): 357 | """Return True if the evaluator is a proxy.""" 358 | return False 359 | 360 | @property 361 | def rule_count(self): 362 | """Return the number of rules included in this evaluator.""" 363 | return 1 364 | 365 | 366 | class RegexRule(Rule): 367 | """Base class for code analyzer rules based on regular expressions. 368 | 369 | The following configuration options apply: 370 | - "pattern": a string with the pattern to search for 371 | - "invert": a boolean indicating if the match should be inverted; if 372 | false (default), matches of the pattern are reported; if true, a 373 | single report is raised if the pattern is not matched (optional) 374 | """ 375 | 376 | def __init__(self, config): 377 | """Construct a rule with the given configuration.""" 378 | super(RegexRule, self).__init__(config) 379 | if config is None: 380 | raise ValueError('no configuration provided') 381 | pattern = config.get('pattern') 382 | if pattern: 383 | if type(pattern) is str: 384 | self.regex = re.compile(pattern.encode()) 385 | elif type(pattern) is bytes: 386 | self.regex = re.compile(pattern) 387 | else: 388 | raise ValueError('unexpected type for pattern: ' 389 | f'{type(pattern)}') 390 | else: 391 | raise ValueError('empty pattern is not allowed') 392 | self.invert = config.get('invert', False) 393 | default_msg = f'Pattern {"not " if self.invert else ""}matched' 394 | self.default_message = config.get('description', default_msg) 395 | 396 | def __str__(self): 397 | """Return a string representation of the rule.""" 398 | return (f'{self.__class__.__name__} = {"{"}pattern: ' 399 | f'"{self.regex.pattern.decode()}", invert: {self.invert}, ' 400 | f'default_message: "{self.default_message}"{"}"}') 401 | 402 | def evaluate(self, source): 403 | """Evaluate the rule against the referenced source. 404 | 405 | source can be a path to a file or an open file-like object. 406 | Subclasses are free to treat source differently (e.g., a string 407 | with the code to analyze, a parse tree, etc.). Unlike its 408 | superclass, RegexRule treats files in binary mode. 409 | 410 | Returns a list of Report objects. 411 | """ 412 | _logger.debug(f'Evaluating {self.__class__.__name__}') 413 | if source is None: 414 | raise ValueError(f'"{source}" cannot be None') 415 | if type(source) is str: 416 | if os.path.isfile(source): 417 | with open(source, 'rb') as source_file: 418 | reports = self.evaluate_file(source_file) 419 | else: 420 | raise ValueError(f'"{source}" not found or not a file') 421 | else: 422 | reports = self.evaluate_file(source) 423 | return reports 424 | 425 | def evaluate_file(self, source_file): 426 | """Evaluate the rule against the provided file-like object. 427 | 428 | The file must already be open. 429 | 430 | Returns a list of Report objects. 431 | """ 432 | reports = [] 433 | try: 434 | contents = mmap.mmap(source_file.fileno(), 0, 435 | access=mmap.ACCESS_READ) 436 | empty = False 437 | except ValueError: 438 | empty = True 439 | if not empty: 440 | if self.invert and not self.intervals: 441 | match = self.regex.search(contents) 442 | # Not matching is an error 443 | if not match: 444 | pattern_str = self.regex.pattern.decode() 445 | report = Report( 446 | self.code, self.default_message, 447 | report_type=self.default_report_type, 448 | detail=f'The pattern "{pattern_str}" was not found.') 449 | reports.append(report) 450 | else: 451 | matches = self.regex.finditer(contents) 452 | for match in matches: 453 | start = match.start() 454 | end = match.end() 455 | contents.seek(0) 456 | before = contents.read(start).decode() 457 | text = contents.read(end - start).decode() 458 | line_start = before.count('\r') + 1 459 | if line_start == 1: 460 | # Match starts on line 1 461 | column = start + 1 462 | else: 463 | # Match starts on another line 464 | last_newline = before.rfind('\n') 465 | if last_newline == -1: 466 | last_newline = before.rfind('\r') 467 | column = start - last_newline 468 | if self.intervals: 469 | # Calculate end line of match 470 | line_end = line_start + text.rstrip('\r\n').count('\r') 471 | else: 472 | # End line is unimportant 473 | line_end = line_start 474 | if self.is_position_in_intervals(line_start, 475 | position_end=line_end): 476 | if self.invert: 477 | break 478 | else: 479 | report = Report( 480 | self.code, self.default_message, 481 | report_type=self.default_report_type, 482 | line=line_start, column=column, 483 | text=text) 484 | reports.append(report) 485 | else: 486 | if self.invert: 487 | pattern_str = self.regex.pattern.decode() 488 | report = Report( 489 | self.code, self.default_message, 490 | report_type=self.default_report_type, 491 | detail=(f'The pattern "{pattern_str}" was not ' 492 | 'found.')) 493 | reports.append(report) 494 | return reports 495 | 496 | 497 | class Proxy(Evaluator): 498 | """Proxy class for grouping evaluators. 499 | 500 | Proxies will typically be used to avoid processing the same file 501 | multiple times during load. 502 | 503 | The following configuration options apply: 504 | - "evaluators": a list of configurations for evaluators that will be 505 | executed in the stated order 506 | """ 507 | 508 | def __init__(self, config): 509 | """Construct a PeopleCode parser proxy for the given file. 510 | 511 | Initialization will be lazy. 512 | """ 513 | super(Proxy, self).__init__(config) 514 | self.file_path = None 515 | self.source_type = None 516 | self.evaluators = [_create_evaluator(ev) for ev in 517 | config['evaluators']] 518 | 519 | def __str__(self): 520 | """Return a string representation of the proxy.""" 521 | out = self.__class__.__name__ + ' = {evaluators: [' 522 | for i, ev in enumerate(self.evaluators): 523 | if i > 0: 524 | out += ', ' 525 | out += ev.__class__.__name__ 526 | out += ']}' 527 | return out 528 | 529 | def reset(self): 530 | """Reset the proxy for a new source.""" 531 | for ev in self.evaluators: 532 | ev.reset() 533 | ev.intervals = self.intervals.copy() 534 | 535 | def attach(self, file_path, source_type=None): 536 | """Attach the Proxy to a specific file and reset it. 537 | 538 | Does nothing if already attached to that file. 539 | """ 540 | self.source_type = source_type 541 | if (self.file_path is None 542 | or not os.path.samefile(self.file_path, file_path)): 543 | self.file_path = file_path 544 | for ev in self.evaluators: 545 | if ev.is_proxy: 546 | ev.attach(file_path, source_type=source_type) 547 | self.reset() 548 | 549 | def _evaluate_evaluator(self, evaluator, exhaustive): 550 | """Evaluate an evaluator, returning its reports.""" 551 | if evaluator.is_proxy: 552 | return self._evaluate_proxy(evaluator, exhaustive) 553 | else: 554 | return self._evaluate_rule(evaluator) 555 | 556 | def _evaluate_rule(self, rule): 557 | """Evaluate a rule, returning its reports.""" 558 | return rule.evaluate(self.file_path) 559 | 560 | def _evaluate_proxy(self, proxy, exhaustive): 561 | """Evaluate a proxy, returning its reports.""" 562 | return proxy.evaluate(exhaustive=exhaustive) 563 | 564 | def _propagate_state(self, previous_ev, current_ev): 565 | """Propagate state to another evaluator. 566 | 567 | Abstract method allowing subclasses to copy state from one 568 | evaluator to the next if necessary. Not declared as an 569 | @abstractmethod because subclasses may wish to never propagate. 570 | """ 571 | pass 572 | 573 | def evaluate(self, exhaustive=False): 574 | """Evaluate the proxied evaluators against the attached file path. 575 | 576 | Returns a list of Report objects. 577 | """ 578 | _logger.debug(f'Evaluating {self.__class__.__name__}') 579 | all_reports = [] 580 | if self.file_path: 581 | for i, ev in enumerate(self.evaluators): 582 | if i > 0: 583 | self._propagate_state(self.evaluators[i - 1], ev) 584 | if ev.is_proxy or ev.applies_to_source_type(self.source_type): 585 | reports = self._evaluate_evaluator(ev, exhaustive) 586 | if reports: 587 | all_reports.extend(reports) 588 | if not exhaustive: 589 | for r in reports: 590 | if r.is_error: 591 | break 592 | else: 593 | # Inner loop did not break 594 | continue 595 | # Inner loop ended in break 596 | break 597 | return all_reports 598 | 599 | @property 600 | def is_proxy(self): 601 | """Return True if the evaluator is a proxy.""" 602 | return True 603 | 604 | @property 605 | def rule_count(self): 606 | """Return the number of rules included in this evaluator.""" 607 | count = 0 608 | for ev in self.evaluators: 609 | count += ev.rule_count 610 | return count 611 | 612 | 613 | # PRIVATE FUNCTIONS 614 | def _print_verbose(text, end='\n', flush=True): 615 | """Print to stdout if verbose output is enabled.""" 616 | if _verbose: 617 | print(text, end=end, flush=flush) 618 | 619 | 620 | def _load_config(path, profile, substitutions): 621 | """Load the configuration from a JSON file.""" 622 | _logger.info(f'Loading profile "{profile}" from file "{path}"') 623 | if os.path.isfile(path): 624 | with open(path) as config_file: 625 | config = json.load(config_file) 626 | if config: 627 | config_profile = config['profiles'][profile] 628 | config_subs = config_profile.get('substitutions') 629 | global _config_evaluators 630 | _config_evaluators = config_profile['evaluators'] 631 | if config_subs or substitutions: 632 | subs = dict(config_subs) 633 | if substitutions: 634 | subs.update(substitutions) 635 | for ev in _config_evaluators: 636 | _do_config_substitutions(ev, subs) 637 | else: 638 | raise ValueError(f'File "{path}" not found') 639 | 640 | 641 | def _do_config_substitutions(config, substitutions): 642 | """Perform configuration value substitutions from a dictionary. 643 | 644 | This function will be called recursively, where config will be 645 | either a dictionary loaded from JSON or a list of such dictionaries. 646 | """ 647 | if substitutions: 648 | if isinstance(config, dict): 649 | # Loop over dictionary elements 650 | for key in iter(config): 651 | val = config[key] 652 | if isinstance(val, (dict, list)): 653 | _do_config_substitutions(val, substitutions) 654 | elif isinstance(val, str): 655 | for old in iter(substitutions): 656 | val = val.replace(f'#{old}#', substitutions[old]) 657 | config[key] = val 658 | elif isinstance(config, list): 659 | # Loop over list items recursively 660 | for item in config: 661 | _do_config_substitutions(item, substitutions) 662 | 663 | 664 | def _create_evaluator(config): 665 | """Create an evaluator based on the given configuration.""" 666 | class_name = config['class'] 667 | parts = class_name.rsplit(sep='.', maxsplit=1) 668 | if len(parts) == 1: 669 | _logger.debug(f'Creating evaluator {parts[-1]}') 670 | ev = globals()[parts[-1]](config) 671 | else: 672 | module = sys.modules.get(parts[0]) 673 | if not module: 674 | _logger.debug(f'Importing module {parts[0]}') 675 | module = importlib.import_module(parts[0]) 676 | _logger.debug(f'Creating evaluator {parts[-1]}') 677 | ev = getattr(module, parts[-1])(config) 678 | return ev 679 | 680 | 681 | def _flatten(lst): 682 | """Generate individual items from a multiple-level list.""" 683 | for elem in lst: 684 | if isinstance(elem, Iterable) and not isinstance(elem, (str, bytes)): 685 | yield from _flatten(elem) 686 | else: 687 | yield elem 688 | 689 | 690 | def _process_input(args): 691 | """Process an input argument, globbing files and directories.""" 692 | for arg in _flatten([glob.glob(file) for file in args]): 693 | if os.path.exists(arg): 694 | if os.path.isfile(arg): 695 | yield arg 696 | elif os.path.isdir(arg): 697 | directory = os.walk(arg) 698 | for adir in directory: 699 | base_dir = adir[0] 700 | for filename in adir[2]: 701 | yield os.path.join(base_dir, filename) 702 | else: 703 | _logger.warning(f'"{arg}" neither a file nor a directory, ' 704 | 'skipping.') 705 | else: 706 | _logger.warning(f'"{arg}" not found, skipping.') 707 | 708 | 709 | # PUBLIC FUNCTIONS 710 | def get_user_config_directory(create_if_missing=True): 711 | """Return the path to the configuration directory. 712 | 713 | Optionally creates the directory if missing. 714 | """ 715 | home_dir = os.environ[ 716 | 'USERPROFILE' if platform.system() == 'Windows' else 'HOME' 717 | ] 718 | config_dir = os.path.join(home_dir, '.pscodeanalyzer') 719 | if create_if_missing: 720 | os.makedirs(config_dir, exist_ok=True) 721 | return config_dir 722 | 723 | 724 | def analyze(source_files, config_file, profile='default', substitutions=None, 725 | exhaustive=True, verbose_output=False): 726 | """Analyze the source code in the specified source files. 727 | 728 | The source_files argument must be a list whose items are either: 729 | - strings representing paths to files 730 | - two-item tuples whose first item is a string representing the path 731 | to a file and the second item is an integer denoting the type of 732 | the source file (can be None, in which case it is ignored) 733 | - three-item tuples whose first item is a string representing the 734 | path to a file, the second item is an integer denoting the type of 735 | the source file (can be None, in which case it is ignored), and 736 | the third item is a list of two-item tuples denoting intervals 737 | within which to limit the analysis 738 | 739 | Returns a list of FileReports objects. 740 | """ 741 | global _verbose 742 | _verbose = verbose_output 743 | evaluators = [] 744 | if source_files: 745 | _load_config(config_file, profile, substitutions) 746 | if _config_evaluators: 747 | for config in _config_evaluators: 748 | evaluators.append(_create_evaluator(config)) 749 | if evaluators: 750 | file_reports = [] 751 | for src in source_files: 752 | intervals = None 753 | if type(src) is str: 754 | file_path = src 755 | source_type = None 756 | else: 757 | file_path = src[0] 758 | source_type = src[1] 759 | if len(src) > 2: 760 | intervals = src[2] 761 | reports = [] 762 | for ev in evaluators: 763 | if intervals: 764 | for iv in intervals: 765 | ev.add_interval(iv[0], iv[1]) 766 | if ev.is_proxy: 767 | ev.attach(file_path, source_type=source_type) 768 | else: 769 | if ev.applies_to_source_type(source_type): 770 | ev.reset() 771 | else: 772 | _logger.debug( 773 | f'- Evaluator: {ev.__class__.__name__} (not ' 774 | f'applicable for source type {str(source_type)})') 775 | continue 776 | ev_rep = ev.evaluate(file_path) 777 | _logger.debug(f'- Evaluator: {ev.__class__.__name__} ' 778 | f'({str(len(ev_rep))} report(s))') 779 | reports.extend(ev_rep) 780 | if reports: 781 | fr = FileReports(file_path, source_type=source_type, 782 | reports=reports) 783 | file_reports.append(fr) 784 | status = fr.cumulative_status 785 | _print_verbose(fr) 786 | if not exhaustive and status == ReportType.ERROR: 787 | break 788 | # else: 789 | # _print_verbose(f'{os.path.basename(file_path)}: no reports') 790 | return file_reports 791 | else: 792 | print(f'No rules to evaluate in profile "{profile}" of configuration ' 793 | f'file "{config_file}"', file=sys.stderr) 794 | sys.exit(1) 795 | 796 | 797 | if __name__ == '__main__': 798 | assert sys.version_info >= (3, 6), \ 799 | 'Python 3.6+ is required to run this script' 800 | default_config_file_path = os.path.join(get_user_config_directory(), 801 | default_config_file_name) 802 | parser = argparse.ArgumentParser( 803 | prog='pscodeanalyzer.engine', 804 | description='Performs static code analysis on source files.') 805 | parser.add_argument( 806 | '-v', '--verbosity', action='count', default=0, 807 | help='increase output verbosity') 808 | parser.add_argument( 809 | '-c', '--configfile', default=default_config_file_path, 810 | help=('the configuration file to use (defaults to ' 811 | f'{default_config_file_path})')) 812 | parser.add_argument( 813 | '-p', '--profile', default='default', 814 | help=('the profile to use within the configuration file (defaults to ' 815 | '"default")')) 816 | parser.add_argument( 817 | '-s', '--substitute', metavar='"VARIABLE=value"', action='append', 818 | help=('specify a variable substitution for the configuration profile ' 819 | '(can be specified multiple times)')) 820 | parser.add_argument( 821 | 'files', metavar='file_or_dir', nargs='+', 822 | help=('one or more source files or directories to process recursively ' 823 | '(wildcards accepted)')) 824 | args = parser.parse_args() 825 | if args.verbosity == 2: 826 | logging.basicConfig(level=logging.INFO) 827 | elif args.verbosity > 2: 828 | logging.basicConfig(level=logging.DEBUG) 829 | else: 830 | logging.basicConfig() 831 | if args.substitute: 832 | substitutions = {} 833 | for s in args.substitute: 834 | # Remove leading spaces 835 | sub = s.lstrip() 836 | # Ignore empty substitutions 837 | if sub: 838 | parts = sub.split(sep='=', maxsplit=1) 839 | if parts[0]: 840 | substitutions[parts[0]] = '' if len(parts) == 1 \ 841 | else parts[1] 842 | else: 843 | parser.error(f'Invalid substitution: "{s}"') 844 | if len(substitutions) == 0: 845 | substitutions = None 846 | else: 847 | _logger.info(f'substitutions = {substitutions}') 848 | else: 849 | substitutions = None 850 | file_reports = analyze(list(_process_input(args.files)), args.configfile, 851 | profile=args.profile, substitutions=substitutions, 852 | verbose_output=(args.verbosity > 0)) 853 | for fr in file_reports: 854 | if fr.is_error: 855 | sys.exit(1) 856 | -------------------------------------------------------------------------------- /pscodeanalyzer/rules/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | -------------------------------------------------------------------------------- /pscodeanalyzer/rules/peoplecode.py: -------------------------------------------------------------------------------- 1 | """Static code analyzer rules for PeopleCode.""" 2 | 3 | import logging 4 | from abc import ABC 5 | from collections import OrderedDict 6 | 7 | from antlr4 import CommonTokenStream, FileStream, ParseTreeWalker 8 | from antlr4.error.ErrorListener import ErrorListener 9 | 10 | from peoplecodeparser.PeopleCodeLexer import PeopleCodeLexer 11 | from peoplecodeparser.PeopleCodeParser import PeopleCodeParser 12 | from peoplecodeparser.PeopleCodeParserListener import PeopleCodeParserListener 13 | 14 | from ..engine import Proxy, Report, Rule 15 | 16 | 17 | # GLOBAL VARIABLES 18 | _logger = logging.getLogger('peoplecode') 19 | 20 | 21 | # PARSER INFRASTRUCTURE CLASSES 22 | class ReportingErrorListener(ErrorListener): 23 | """An error listener for the PeopleCode parser. 24 | 25 | It outputs errors as Report objects. 26 | """ 27 | 28 | def __init__(self, reports): 29 | """Initialize the error listener.""" 30 | super(ReportingErrorListener, self).__init__() 31 | self.reports = reports 32 | 33 | def syntaxError(self, recognizer, offendingSymbol, line, column, msg, e): 34 | """Handle a syntax error.""" 35 | _logger.debug(f'Syntax error at {line},{column + 1}: {msg}') 36 | report = Report('PeopleCodeParser', msg, line=line, 37 | column=(column + 1)) 38 | self.reports.append(report) 39 | 40 | 41 | # PROXIES 42 | class PeopleCodeParserProxy(Proxy): 43 | """Proxy class for rules that require the PeopleCode parser. 44 | 45 | It will handle the parsing optimally to avoid parsing the same file 46 | multiple times. It will assume that all evaluators that are not 47 | themselves proxies will be subclasses of 48 | PeopleCodeParserListenerRule. 49 | 50 | The following configuration options apply: 51 | - "encoding": the encoding with which to open the source files 52 | (optional, defaults to "utf-8") 53 | """ 54 | 55 | def __init__(self, config): 56 | """Construct a PeopleCode parser proxy. 57 | 58 | Initialization will be lazy. 59 | """ 60 | super(PeopleCodeParserProxy, self).__init__(config) 61 | self.encoding = config.get('encoding', 'utf-8') 62 | 63 | def reset(self): 64 | """Reset the proxy for a new source.""" 65 | super(PeopleCodeParserProxy, self).reset() 66 | self.parse_reports = [] 67 | self.starting_rule = 'appClass' if self.source_type == 58 \ 68 | or (self.source_type is None 69 | and self.file_path 70 | and '058-' in self.file_path) else 'program' 71 | self._file_stream = None 72 | self._lexer = None 73 | self._token_stream = None 74 | self._parser = None 75 | self._parse_tree = None 76 | self._walker = None 77 | 78 | @property 79 | def file_stream(self): 80 | """Lazy initialization of file_stream.""" 81 | if self._file_stream is None: 82 | self._file_stream = FileStream(self.file_path, 83 | encoding=self.encoding) 84 | return self._file_stream 85 | 86 | @property 87 | def lexer(self): 88 | """Lazy initialization of lexer.""" 89 | if self._lexer is None: 90 | self._lexer = PeopleCodeLexer(self.file_stream) 91 | return self._lexer 92 | 93 | @property 94 | def token_stream(self): 95 | """Lazy initialization of token_stream.""" 96 | if self._token_stream is None: 97 | self._token_stream = CommonTokenStream(self.lexer) 98 | return self._token_stream 99 | 100 | @property 101 | def parser(self): 102 | """Lazy initialization of parser.""" 103 | if self._parser is None: 104 | self._parser = PeopleCodeParser(self.token_stream) 105 | self._parser.removeErrorListeners() 106 | self.parse_reports = [] 107 | listener = ReportingErrorListener(self.parse_reports) 108 | self._parser.addErrorListener(listener) 109 | return self._parser 110 | 111 | @property 112 | def parse_tree(self): 113 | """Lazy initialization of parse_tree.""" 114 | if self._parse_tree is None: 115 | _logger.info(f'{self.__class__.__name__}:Parsing file ' 116 | f'"{self.file_path}"...') 117 | self._parse_tree = getattr(self.parser, self.starting_rule)() 118 | _logger.info(f'{self.__class__.__name__}:Parsing complete') 119 | return self._parse_tree 120 | 121 | @property 122 | def walker(self): 123 | """Lazy initialization of walker.""" 124 | if self._walker is None: 125 | self._walker = ParseTreeWalker() 126 | return self._walker 127 | 128 | def _propagate_state(self, previous_ev, current_ev): 129 | """Copy the annotations between subsequent evaluators.""" 130 | if (isinstance(previous_ev, PeopleCodeParserListenerRule) 131 | and isinstance(current_ev, PeopleCodeParserListenerRule)): 132 | if current_ev.inherit_annotations: 133 | current_ev.annotations = previous_ev.annotations 134 | 135 | def _evaluate_rule(self, rule): 136 | """Evaluate a rule, returning its reports.""" 137 | tree = self.parse_tree 138 | walker = self.walker 139 | try: 140 | walker.walk(rule, tree) 141 | return rule.evaluate() 142 | except NotApplicableError as e: 143 | _logger.debug(f'{self.__class__.__name__}:{str(e)}') 144 | return [] 145 | 146 | def evaluate(self, exhaustive=False): 147 | """Extend the superclass's evaluation results with any errors.""" 148 | _logger.debug(f'Evaluating {self.__class__.__name__}') 149 | super_reports = super(PeopleCodeParserProxy, self).evaluate( 150 | exhaustive=exhaustive) 151 | all_reports = self.parse_reports + super_reports 152 | return all_reports 153 | 154 | 155 | # RULES 156 | class PeopleCodeParserListenerRule(Rule, PeopleCodeParserListener): 157 | """Base class for rules that are PeopleCodeParserListener instances. 158 | 159 | The following configuration options apply: 160 | - "inherit_annotations": indicates whether this rule should inherit 161 | the annotations from the rule that ran before it (optional, 162 | defaults to False) 163 | """ 164 | 165 | def __init__(self, config): 166 | """Initialize the rule. 167 | 168 | The annotations dictionary can be used to map tree nodes to 169 | values. This facility be useful if one listener needs to 170 | annotate the tree for another downstream listener. 171 | """ 172 | super(PeopleCodeParserListenerRule, self).__init__(config) 173 | self.annotations = {} 174 | self.inherit_annotations = config.get('inherit_annotations', False) 175 | 176 | def reset(self): 177 | """Reset the rule for a new evaluation.""" 178 | super(PeopleCodeParserListenerRule, self).reset() 179 | self.reports = [] 180 | 181 | def evaluate(self, source=None): 182 | """Return the list of Report objects generated by the rule.""" 183 | _logger.debug(f'Evaluating {self.__class__.__name__}') 184 | return self.reports 185 | 186 | 187 | class SQLExecRule(PeopleCodeParserListenerRule): 188 | """Rule to check for SQLExec calls with literal SQL statements.""" 189 | 190 | def __init__(self, config): 191 | """Initialize the rule.""" 192 | super(SQLExecRule, self).__init__(config) 193 | 194 | # Enter a parse tree produced by PeopleCodeParser#simpleFunctionCall. 195 | def enterSimpleFunctionCall( 196 | self, ctx: PeopleCodeParser.SimpleFunctionCallContext): 197 | """Event triggered when a simple function call is found.""" 198 | line = ctx.start.line 199 | if self.is_position_in_intervals(line): 200 | function_name = ctx.genericID().allowableFunctionName() 201 | if function_name and function_name.getText().upper() == 'SQLEXEC': 202 | args = ctx.functionCallArguments() 203 | if args: 204 | expr = args.expression(i=0) 205 | if hasattr(expr, 'literal'): 206 | message = 'SQLExec with literal first argument' 207 | elif hasattr(expr, 'PIPE'): 208 | message = 'SQLExec with concatenated first argument' 209 | else: 210 | message = None 211 | if message: 212 | report = Report( 213 | self.code, message, 214 | line=line, column=(ctx.start.column + 1), 215 | text=ctx.getText(), 216 | detail=('The first argument to SQLExec should be ' 217 | 'either a SQL object reference or a ' 218 | 'variable with dynamically generated ' 219 | 'SQL.')) 220 | self.reports.append(report) 221 | else: 222 | # Should never happen in valid PeopleCode 223 | report = Report( 224 | self.code, 'SQLExec with no arguments', line=line, 225 | column=(ctx.start.column + 1), text=ctx.getText(), 226 | detail=('SQLExec should not be called without ' 227 | 'arguments.')) 228 | self.reports.append(report) 229 | 230 | 231 | # SYMBOL RESOLUTION CLASSES 232 | class NotApplicableError(Exception): 233 | """An exception when a listener is not applicable to a given input. 234 | 235 | For example, if SymbolDefinitionPhaseRule were to be called for an 236 | Application Class, it would raise this exception. 237 | """ 238 | 239 | pass 240 | 241 | 242 | class SymbolDefinitionPhaseRule(PeopleCodeParserListenerRule): 243 | """A listener that defines symbols for later reference. 244 | 245 | It is suitable for testing for undeclared variables. As such, should 246 | never be used with Application Classes, where undeclared variables 247 | are forbidden by design. 248 | """ 249 | 250 | def __init__(self, config): 251 | """Initialize the rule.""" 252 | super(SymbolDefinitionPhaseRule, self).__init__(config) 253 | 254 | def _save_scope(self, ctx, scope): 255 | """Annotate the tree node with the scope.""" 256 | self.annotations[ctx.start.tokenIndex] = scope 257 | 258 | def _define_variable(self, token): 259 | """Define a variable or argument in the current scope.""" 260 | var = VariableSymbol(token.getText(), token.getSourceInterval()[0]) 261 | self.current_scope.define(var) 262 | 263 | def _pop_scope(self): 264 | """Pop the scope.""" 265 | self._log_current_scope() 266 | self.current_scope = self.current_scope.parent_scope 267 | 268 | def _log_current_scope(self): 269 | """Output the current scope (if not empty) to the logger.""" 270 | if _logger.isEnabledFor(logging.DEBUG): 271 | if not self.current_scope.is_empty: 272 | _logger.debug(f'{self.__class__.__name__}:' 273 | f'{str(self.current_scope)}') 274 | 275 | # Enter a parse tree produced by PeopleCodeParser#AppClassProgram. 276 | def enterAppClassProgram( 277 | self, ctx: PeopleCodeParser.AppClassProgramContext): 278 | """Raise an exception. 279 | 280 | Application Classes should not be subjected to this listener. 281 | """ 282 | _logger.debug(f'{self.__class__.__name__}:>>> ' 283 | f'#AppClassProgram@{ctx.start.line},' 284 | f'{ctx.start.column + 1}') 285 | raise NotApplicableError('This listener should not be used for ' 286 | 'Application Classes') 287 | 288 | # Enter a parse tree produced by PeopleCodeParser#InterfaceProgram. 289 | def enterInterfaceProgram( 290 | self, ctx: PeopleCodeParser.InterfaceProgramContext): 291 | """Raise an exception. 292 | 293 | Application Classes should not be subjected to this listener. 294 | """ 295 | _logger.debug(f'{self.__class__.__name__}:>>> ' 296 | f'#InterfaceProgram@{ctx.start.line},' 297 | f'{ctx.start.column + 1}') 298 | raise NotApplicableError('This listener should not be used for ' 299 | 'Application Classes') 300 | 301 | # Enter a parse tree produced by PeopleCodeParser#program. 302 | def enterProgram(self, ctx: PeopleCodeParser.ProgramContext): 303 | """Initialize the scoping mechanism.""" 304 | _logger.debug(f'{self.__class__.__name__}:>>> ' 305 | f'#program@{ctx.start.line},{ctx.start.column + 1}') 306 | self.annotations = {} 307 | self.current_scope = GlobalScope() 308 | # Annotate the root node with the global scope for the 309 | # SymbolReferencePhaseRule 310 | self._save_scope(ctx, self.current_scope) 311 | 312 | # Enter a parse tree produced by PeopleCodeParser#functionDefinition. 313 | def enterFunctionDefinition( 314 | self, ctx: PeopleCodeParser.FunctionDefinitionContext): 315 | """Start a new function scope.""" 316 | _logger.debug(f'{self.__class__.__name__}:>>> ' 317 | f'#functionDefinition@{ctx.start.line},' 318 | f'{ctx.start.column + 1}') 319 | name = ctx.allowableFunctionName().getText() 320 | scope = FunctionScope(name, self.current_scope) 321 | self.current_scope = scope 322 | self._save_scope(ctx, scope) 323 | 324 | # Exit a parse tree produced by PeopleCodeParser#functionArgument. 325 | def exitFunctionArgument( 326 | self, ctx: PeopleCodeParser.FunctionArgumentContext): 327 | """Add a function argument to the current scope.""" 328 | _logger.debug(f'{self.__class__.__name__}:<<< ' 329 | f'#functionArgument@{ctx.stop.line},' 330 | f'{ctx.stop.column + 1}') 331 | self._define_variable(ctx.USER_VARIABLE()) 332 | 333 | # Exit a parse tree produced by PeopleCodeParser#functionDefinition. 334 | def exitFunctionDefinition( 335 | self, ctx: PeopleCodeParser.FunctionDefinitionContext): 336 | """Pop the scope.""" 337 | _logger.debug(f'{self.__class__.__name__}:<<< ' 338 | f'#functionDefinition@{ctx.stop.line},' 339 | f'{ctx.stop.column + 1}') 340 | self._pop_scope() 341 | 342 | # Exit a parse tree produced by PeopleCodeParser#nonLocalVarDeclaration. 343 | def exitNonLocalVarDeclaration( 344 | self, ctx: PeopleCodeParser.NonLocalVarDeclarationContext): 345 | """Add global/component variables to the current scope. 346 | 347 | The current scope should be the global scope. 348 | """ 349 | _logger.debug(f'{self.__class__.__name__}:<<< ' 350 | f'#nonLocalVarDeclaration@{ctx.stop.line},' 351 | f'{ctx.stop.column + 1}') 352 | var_tokens = ctx.USER_VARIABLE() 353 | if var_tokens: 354 | for var in var_tokens: 355 | self._define_variable(var) 356 | 357 | # Exit a parse tree produced by PeopleCodeParser#localVariableDefinition. 358 | def exitLocalVariableDefinition( 359 | self, ctx: PeopleCodeParser.LocalVariableDefinitionContext): 360 | """Add local variables to the current scope.""" 361 | _logger.debug(f'{self.__class__.__name__}:<<< ' 362 | f'#localVariableDefinition@{ctx.stop.line},' 363 | f'{ctx.stop.column + 1}') 364 | var_tokens = ctx.USER_VARIABLE() 365 | if var_tokens: 366 | for var in var_tokens: 367 | self._define_variable(var) 368 | 369 | # Exit a parse tree produced by 370 | # PeopleCodeParser#localVariableDeclAssignment. 371 | def exitLocalVariableDeclAssignment( 372 | self, ctx: PeopleCodeParser.LocalVariableDeclAssignmentContext): 373 | """Add a local variable to the current scope.""" 374 | _logger.debug(f'{self.__class__.__name__}:<<< ' 375 | f'#localVariableDeclAssignment@{ctx.stop.line},' 376 | f'{ctx.stop.column + 1}') 377 | self._define_variable(ctx.USER_VARIABLE()) 378 | 379 | # Exit a parse tree produced by PeopleCodeParser#constantDeclaration. 380 | def exitConstantDeclaration( 381 | self, ctx: PeopleCodeParser.ConstantDeclarationContext): 382 | """Add a constant to the current scope.""" 383 | _logger.debug(f'{self.__class__.__name__}:<<< ' 384 | f'#constantDeclaration@{ctx.stop.line},' 385 | f'{ctx.stop.column + 1}') 386 | self._define_variable(ctx.USER_VARIABLE()) 387 | 388 | # Enter a parse tree produced by PeopleCodeParser#statementBlock. 389 | def enterStatementBlock(self, ctx: PeopleCodeParser.StatementBlockContext): 390 | """Push a new local scope into the stack.""" 391 | _logger.debug(f'{self.__class__.__name__}:>>> ' 392 | f'#statementBlock@{ctx.start.line},' 393 | f'{ctx.start.column + 1}') 394 | scope = LocalScope(f'local@{ctx.start.line},{ctx.start.column + 1}', 395 | self.current_scope) 396 | self.current_scope = scope 397 | self._save_scope(ctx, scope) 398 | 399 | # Exit a parse tree produced by PeopleCodeParser#statementBlock. 400 | def exitStatementBlock(self, ctx: PeopleCodeParser.StatementBlockContext): 401 | """Pops the scope.""" 402 | _logger.debug(f'{self.__class__.__name__}:<<< ' 403 | f'#statementBlock@{ctx.stop.line},{ctx.stop.column + 1}') 404 | self._pop_scope() 405 | 406 | # Exit a parse tree produced by PeopleCodeParser#program. 407 | def exitProgram(self, ctx: PeopleCodeParser.ProgramContext): 408 | """Signifies the end of the parse.""" 409 | _logger.debug(f'{self.__class__.__name__}:<<< ' 410 | f'#program@{ctx.stop.line},{ctx.stop.column + 1}') 411 | self._log_current_scope() 412 | 413 | 414 | class SymbolReferencePhaseRule(PeopleCodeParserListenerRule): 415 | """A listener that references symbols defined earlier. 416 | 417 | The symbols are stored in the annotations dictionary, to check for 418 | undeclared variables. It should never be used with Application 419 | Classes, where undeclared variables are forbidden by design. 420 | """ 421 | 422 | def __init__(self, config): 423 | """Initialize the rule.""" 424 | super(SymbolReferencePhaseRule, self).__init__(config) 425 | 426 | def _resolve_variable(self, token): 427 | """Resolve a variable defined earlier.""" 428 | name = token.getText() 429 | token_symbol = token.getSymbol() 430 | line = token_symbol.line 431 | if self.is_position_in_intervals(line): 432 | symbol = self.current_scope.resolve(name) 433 | if symbol is None: 434 | # Symbol not found anywhere 435 | report = Report( 436 | self.code, f'Undeclared variable {name}', line=line, 437 | column=(token_symbol.column + 1), text=name, 438 | detail=(f'{name} does not resolve to any variable, ' 439 | 'constant or function argument in scope.')) 440 | self.reports.append(report) 441 | elif token_symbol.tokenIndex < symbol.index: 442 | # Symbol found, but it is defined after it is referenced 443 | report = Report( 444 | self.code, 445 | f'Variable {name} is referenced before it is declared', 446 | line=line, column=(token_symbol.column + 1), text=name, 447 | detail=(f'{name} is referenced before it is declared as a ' 448 | 'variable, constant or function argument within ' 449 | 'scope.')) 450 | self.reports.append(report) 451 | 452 | def _set_current_scope(self, ctx): 453 | """Set the current scope.""" 454 | self.current_scope = self.annotations[ctx.start.tokenIndex] 455 | self._log_current_scope() 456 | 457 | def _pop_scope(self): 458 | """Pop the scope.""" 459 | self.current_scope = self.current_scope.parent_scope 460 | self._log_current_scope() 461 | 462 | def _log_current_scope(self): 463 | """Output the current scope (if not empty) to the logger.""" 464 | if _logger.isEnabledFor(logging.DEBUG): 465 | if not self.current_scope.is_empty: 466 | _logger.debug(f'{self.__class__.__name__}:' 467 | f'{str(self.current_scope)}') 468 | 469 | # Enter a parse tree produced by PeopleCodeParser#AppClassProgram. 470 | def enterAppClassProgram( 471 | self, ctx: PeopleCodeParser.AppClassProgramContext): 472 | """Raise an exception. 473 | 474 | Application Classes should not be subjected to this listener. 475 | """ 476 | _logger.debug(f'{self.__class__.__name__}:>>> ' 477 | f'#AppClassProgram@{ctx.start.line},' 478 | f'{ctx.start.column + 1}') 479 | raise NotApplicableError('This listener should not be used for ' 480 | 'Application Classes') 481 | 482 | # Enter a parse tree produced by PeopleCodeParser#InterfaceProgram. 483 | def enterInterfaceProgram( 484 | self, ctx: PeopleCodeParser.InterfaceProgramContext): 485 | """Raise an exception. 486 | 487 | Application Classes should not be subjected to this listener. 488 | """ 489 | _logger.debug(f'{self.__class__.__name__}:>>> ' 490 | f'#InterfaceProgram@{ctx.start.line},' 491 | f'{ctx.start.column + 1}') 492 | raise NotApplicableError('This listener should not be used for ' 493 | 'Application Classes') 494 | 495 | # Enter a parse tree produced by PeopleCodeParser#program. 496 | def enterProgram(self, ctx: PeopleCodeParser.ProgramContext): 497 | """Set the global scope.""" 498 | _logger.debug(f'{self.__class__.__name__}:>>> ' 499 | f'#program@{ctx.start.line},{ctx.start.column + 1}') 500 | self._set_current_scope(ctx) 501 | 502 | # Enter a parse tree produced by PeopleCodeParser#functionDefinition. 503 | def enterFunctionDefinition( 504 | self, ctx: PeopleCodeParser.FunctionDefinitionContext): 505 | """Set the function scope.""" 506 | _logger.debug(f'{self.__class__.__name__}:>>> ' 507 | f'#functionDefinition@{ctx.start.line},' 508 | f'{ctx.start.column + 1}') 509 | self._set_current_scope(ctx) 510 | 511 | # Exit a parse tree produced by PeopleCodeParser#functionDefinition. 512 | def exitFunctionDefinition( 513 | self, ctx: PeopleCodeParser.FunctionDefinitionContext): 514 | """Pop the scope.""" 515 | _logger.debug(f'{self.__class__.__name__}:<<< ' 516 | f'#functionDefinition@{ctx.stop.line},' 517 | f'{ctx.stop.column + 1}') 518 | self._pop_scope() 519 | 520 | # Enter a parse tree produced by PeopleCodeParser#statementBlock. 521 | def enterStatementBlock(self, ctx: PeopleCodeParser.StatementBlockContext): 522 | """Set the local scope.""" 523 | _logger.debug(f'{self.__class__.__name__}:>>> ' 524 | f'#statementBlock@{ctx.start.line},' 525 | f'{ctx.start.column + 1}') 526 | self._set_current_scope(ctx) 527 | 528 | # Exit a parse tree produced by PeopleCodeParser#statementBlock. 529 | def exitStatementBlock(self, ctx: PeopleCodeParser.StatementBlockContext): 530 | """Pop the scope.""" 531 | _logger.debug(f'{self.__class__.__name__}:<<< ' 532 | f'#statementBlock@{ctx.stop.line},{ctx.stop.column + 1}') 533 | self._pop_scope() 534 | 535 | # Enter a parse tree produced by PeopleCodeParser#forStatement. 536 | def enterForStatement(self, ctx: PeopleCodeParser.ForStatementContext): 537 | """Event triggered when a For statement is encountered. 538 | 539 | The goal is to resolve its index variable. 540 | """ 541 | _logger.debug(f'{self.__class__.__name__}:>>> ' 542 | f'#forStatement@{ctx.start.line},{ctx.start.column + 1}') 543 | self._resolve_variable(ctx.USER_VARIABLE()) 544 | 545 | # Exit a parse tree produced by PeopleCodeParser#IdentUserVariable. 546 | def exitIdentUserVariable( 547 | self, ctx: PeopleCodeParser.IdentUserVariableContext): 548 | """Event triggered when a user variable is encountered.""" 549 | _logger.debug(f'{self.__class__.__name__}:<<< ' 550 | f'#IdentUserVariable@{ctx.stop.line},' 551 | f'{ctx.stop.column + 1}') 552 | self._resolve_variable(ctx.USER_VARIABLE()) 553 | 554 | # Exit a parse tree produced by PeopleCodeParser#program. 555 | def exitProgram(self, ctx: PeopleCodeParser.ProgramContext): 556 | """Signify the end of the parse.""" 557 | _logger.debug(f'{self.__class__.__name__}:<<< ' 558 | f'#program@{ctx.stop.line},{ctx.stop.column + 1}') 559 | 560 | 561 | class Scope(ABC): 562 | """Abstract base class for scopes. 563 | 564 | There are no @abstractmethods defined, but this class should never 565 | be instantiated directly. 566 | """ 567 | 568 | def __init__(self, name, parent_scope): 569 | """Initialize the scope. 570 | 571 | The symbols dictionary stores symbols keyed by their names. 572 | """ 573 | self.name = name 574 | self.parent_scope = parent_scope 575 | self._symbols = {} 576 | 577 | def __str__(self): 578 | """Return a string representation of the scope.""" 579 | return (f'{self.qualifier}: ' 580 | f'{str([s.name for s in self.symbols.values()])}') 581 | 582 | def resolve(self, name): 583 | """Resolve a symbol by name.""" 584 | search_name = name.lower() 585 | s = self.symbols.get(search_name) 586 | if s is None: 587 | # Not found in this scope; check parent scope recursively 588 | if self.parent_scope: 589 | s = self.parent_scope.resolve(search_name) 590 | return s 591 | 592 | def define(self, symbol): 593 | """Define a symbol in this scope.""" 594 | symbol.scope = self 595 | self.symbols[symbol.name.lower()] = symbol 596 | 597 | @property 598 | def symbols(self): 599 | """Return the scope's symbols.""" 600 | return self._symbols 601 | 602 | @property 603 | def qualifier(self): 604 | """Return the scope's qualifier.""" 605 | if self.parent_scope: 606 | qual = f'{self.parent_scope.qualifier}.' 607 | else: 608 | qual = '' 609 | return f'{qual}{self.name}' 610 | 611 | @property 612 | def is_empty(self): 613 | """Return True if the scope defines no symbols.""" 614 | return (len(self.symbols) == 0) 615 | 616 | 617 | class GlobalScope(Scope): 618 | """The global scope.""" 619 | 620 | def __init__(self): 621 | """Initialize the scope.""" 622 | super(GlobalScope, self).__init__('global', None) 623 | 624 | 625 | class LocalScope(Scope): 626 | """A local scope.""" 627 | 628 | def __init__(self, name, parent_scope): 629 | """Initialize the scope.""" 630 | super(LocalScope, self).__init__(name, parent_scope) 631 | 632 | 633 | class Symbol: 634 | """Base class for symbols.""" 635 | 636 | def __init__(self, name, index): 637 | """Initialize the symbol.""" 638 | self.name = name 639 | self.index = index 640 | self.scope = None 641 | 642 | def __str__(self): 643 | """Return a string representation of the symbol.""" 644 | return f'{self.name}@{self.index}' 645 | 646 | 647 | class VariableSymbol(Symbol): 648 | """A symbol denoting a variable.""" 649 | 650 | def __init__(self, name, index): 651 | """Initialize the symbol.""" 652 | super(VariableSymbol, self).__init__(name, index) 653 | 654 | 655 | class FunctionScope(Scope): 656 | """A scoped symbol representing a function.""" 657 | 658 | def __init__(self, name, parent_scope): 659 | """Initialize the scoped symbol.""" 660 | super(FunctionScope, self).__init__(name, parent_scope) 661 | self._arguments = OrderedDict() 662 | 663 | @property 664 | def symbols(self): 665 | """Return the scope's symbols.""" 666 | return self._arguments 667 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | license_file=LICENSE.txt 3 | 4 | [bdist_wheel] 5 | universal=0 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | 3 | from setuptools import find_packages, setup 4 | 5 | 6 | here = os.path.abspath(os.path.dirname(__file__)) 7 | 8 | with open(os.path.join(here, 'README.md'), encoding='utf-8') as f: 9 | long_description = f.read() 10 | 11 | setup( 12 | name='pscodeanalyzer', 13 | version='1.2.3', 14 | description='A static code analyzer with configurable plug-in rules', 15 | long_description=long_description, 16 | long_description_content_type='text/markdown', 17 | python_requires='~=3.6', 18 | author='Leandro Baca', 19 | author_email='leandrobaca77@gmail.com', 20 | url='https://github.com/lbaca/PSCodeAnalyzer', 21 | packages=find_packages(), 22 | install_requires=['peoplecodeparser'], 23 | classifiers=[ 24 | 'Development Status :: 5 - Production/Stable', 25 | 'Environment :: Console', 26 | 'Intended Audience :: Developers', 27 | 'Intended Audience :: Information Technology', 28 | 'Programming Language :: Python :: 3', 29 | 'Programming Language :: Python :: 3.6', 30 | 'Programming Language :: Python :: 3.7', 31 | 'Programming Language :: Python :: 3.8', 32 | 'Programming Language :: Python :: 3.9', 33 | 'License :: OSI Approved :: MIT License', 34 | 'Operating System :: OS Independent', 35 | 'Topic :: Software Development', 36 | ], 37 | keywords=('peoplesoft peoplecode source application-class ' 38 | 'application-package code-analysis code-review'), 39 | ) 40 | -------------------------------------------------------------------------------- /tests/plain_text_sample.txt: -------------------------------------------------------------------------------- 1 | This is a thought exercise. 2 | 3 | Imagine, if you will, a language coding style opinionated to believe that lines longer than 79 characters are wrong. 4 | 5 | Let's also detect trailing blanks --> 6 | 7 | This file will only be valid if it has the word FOOBRA in it. ;) 8 | -------------------------------------------------------------------------------- /tests/samplerules/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | -------------------------------------------------------------------------------- /tests/samplerules/model.py: -------------------------------------------------------------------------------- 1 | """Module for sample test rules.""" 2 | 3 | from pscodeanalyzer.engine import Report, Rule 4 | from pscodeanalyzer.rules.peoplecode import PeopleCodeParserListenerRule 5 | from peoplecodeparser.PeopleCodeParser import PeopleCodeParser 6 | 7 | 8 | class LineLengthRule(Rule): 9 | """Rule to enforce maximum line lengths. 10 | 11 | The following configuration options apply: 12 | - "max_length": an integer indicating the maximum acceptable line 13 | length 14 | """ 15 | 16 | def __init__(self, config): 17 | """Construct a rule with the given configuration.""" 18 | super(LineLengthRule, self).__init__(config) 19 | self.max_length = int(config.get('max_length')) 20 | if self.max_length <= 0: 21 | raise ValueError('max_length must be a positive integer') 22 | 23 | def evaluate_file(self, source_file): 24 | """Evaluate the rule against the provided file-like object. 25 | 26 | The file must already be open. 27 | 28 | Returns a list of Report objects. 29 | """ 30 | reports = [] 31 | for line, text in enumerate(source_file, start=1): 32 | if self.is_position_in_intervals(line): 33 | line_length = len(text) 34 | if line_length > self.max_length: 35 | report = Report( 36 | self.code, self.default_message, 37 | report_type=self.default_report_type, line=line, 38 | text=text, 39 | detail=f'Line {line} has a length of {line_length}.') 40 | reports.append(report) 41 | return reports 42 | 43 | 44 | class LocalVariableNamingRule(PeopleCodeParserListenerRule): 45 | """Rule to enforce locally-defined variable naming convention. 46 | 47 | The following configuration options apply: 48 | - "variable_prefix": the prefix with which all locally-defined 49 | variables must begin 50 | """ 51 | 52 | def __init__(self, config): 53 | """Initialize the rule.""" 54 | super(LocalVariableNamingRule, self).__init__(config) 55 | self.variable_prefix = config.get('variable_prefix') 56 | if not self.variable_prefix: 57 | raise ValueError('empty variable_prefix is not allowed') 58 | 59 | def _process_single_variable(self, user_variable): 60 | """Verify if an individual variable name is compliant.""" 61 | if user_variable: 62 | var_name = user_variable.getText() 63 | if not var_name.startswith(self.variable_prefix): 64 | line = user_variable.parentCtx.start.line 65 | column = user_variable.getSymbol().column + 1 66 | message = (f'Variable name "{var_name}" does not start with ' 67 | f'"{self.variable_prefix}"') 68 | report = Report( 69 | self.code, message, line=line, column=column, 70 | text=var_name, 71 | detail=('The variable name does not begin with the prefix ' 72 | f'"{self.variable_prefix}".')) 73 | self.reports.append(report) 74 | 75 | def _verify_user_variables(self, ctx): 76 | """Verify if variable names are compliant. 77 | 78 | The local variable definition parser rule contains a list of 79 | USER_VARIABLE tokens, whereas the local variable declaration and 80 | assignment parser rule contains a single one. 81 | """ 82 | line = ctx.start.line 83 | if self.is_position_in_intervals(line): 84 | user_variable = ctx.USER_VARIABLE() 85 | if type(user_variable) is list: 86 | for uv in user_variable: 87 | self._process_single_variable(uv) 88 | else: 89 | self._process_single_variable(user_variable) 90 | 91 | # Enter a parse tree produced by 92 | # PeopleCodeParser#localVariableDefinition. 93 | def enterLocalVariableDefinition( 94 | self, ctx: PeopleCodeParser.LocalVariableDefinitionContext): 95 | """Event triggered when a local variable definition is found. 96 | 97 | Local variable definitions are of the following forms: 98 | 99 | Local string &var1; 100 | Local number &var2, &var3, &var4; 101 | """ 102 | self._verify_user_variables(ctx) 103 | 104 | # Enter a parse tree produced by 105 | # PeopleCodeParser#localVariableDeclAssignment. 106 | def enterLocalVariableDeclAssignment( 107 | self, ctx: PeopleCodeParser.LocalVariableDeclAssignmentContext): 108 | """Event triggered for local variable definition-assignments. 109 | 110 | These are of the form: 111 | 112 | Local string &var5 = "Some value"; 113 | Local number &var6 = (&var2 + &var3) * &var4; 114 | """ 115 | self._verify_user_variables(ctx) 116 | -------------------------------------------------------------------------------- /tests/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "test_01": { 4 | "evaluators": [ 5 | { 6 | "class": "pscodeanalyzer.rules.peoplecode.PeopleCodeParserProxy", 7 | "description": "PeopleCode parser rule proxy", 8 | "evaluators": [ 9 | { 10 | "class": "pscodeanalyzer.rules.peoplecode.SQLExecRule", 11 | "description": "Look for SQLExec calls with string literals", 12 | "code": 1 13 | } 14 | ] 15 | } 16 | ] 17 | }, 18 | "test_02": { 19 | "evaluators": [ 20 | { 21 | "class": "pscodeanalyzer.rules.peoplecode.PeopleCodeParserProxy", 22 | "description": "PeopleCode parser rule proxy", 23 | "evaluators": [ 24 | { 25 | "class": "pscodeanalyzer.rules.peoplecode.SQLExecRule", 26 | "description": "Look for SQLExec calls with string literals", 27 | "code": 1 28 | }, 29 | { 30 | "class": "pscodeanalyzer.rules.peoplecode.SymbolDefinitionPhaseRule", 31 | "description": "Symbol definition phase for undeclared variable validation", 32 | "code": 9999, 33 | "exclude_source_types": [58] 34 | }, 35 | { 36 | "class": "pscodeanalyzer.rules.peoplecode.SymbolReferencePhaseRule", 37 | "description": "Symbol reference phase for undeclared variable validation", 38 | "code": 2, 39 | "exclude_source_types": [58], 40 | "inherit_annotations": true 41 | } 42 | ] 43 | } 44 | ] 45 | }, 46 | "test_03": { 47 | "evaluators": [ 48 | { 49 | "class": "pscodeanalyzer.rules.peoplecode.PeopleCodeParserProxy", 50 | "description": "PeopleCode parser rule proxy", 51 | "evaluators": [ 52 | { 53 | "class": "samplerules.model.LocalVariableNamingRule", 54 | "description": "Enforce locally-defined variable naming conventions", 55 | "code": 3, 56 | "variable_prefix": "&yo" 57 | } 58 | ] 59 | } 60 | ] 61 | }, 62 | "test_04": { 63 | "substitutions": { 64 | "REQUIRED_WORD": "FOOBAR" 65 | }, 66 | "evaluators": [ 67 | { 68 | "class": "RegexRule", 69 | "description": "Trailing blanks should be avoided", 70 | "code": 4, 71 | "default_report_type": "WARNING", 72 | "pattern": "\\s+$" 73 | }, 74 | { 75 | "class": "RegexRule", 76 | "description": "Required word not found: #REQUIRED_WORD#", 77 | "code": 5, 78 | "default_report_type": "WARNING", 79 | "pattern": "\\b#REQUIRED_WORD#\\b", 80 | "invert": true 81 | }, 82 | { 83 | "class": "samplerules.model.LineLengthRule", 84 | "description": "The line is too long", 85 | "code": 6, 86 | "default_report_type": "WARNING", 87 | "max_length": 79 88 | } 89 | ] 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /tests/test_peoplecode.py: -------------------------------------------------------------------------------- 1 | """PeopleCode analysis tests.""" 2 | 3 | import os.path 4 | import pscodeanalyzer.engine as psca 5 | 6 | 7 | _TESTS_DIR = os.path.dirname(__file__) 8 | _SETTINGS_FILE = os.path.join(_TESTS_DIR, psca.default_config_file_name) 9 | 10 | 11 | def test_app_class_1(): 12 | """Test an Application Class for SQLExec issues.""" 13 | source_file = (os.path.join(_TESTS_DIR, 'HRMH_SETUP.HRMHServices.ppl'), 58) 14 | file_reports = psca.analyze([source_file], _SETTINGS_FILE, 15 | profile='test_01') 16 | reports = file_reports[0].reports 17 | found_errors = {r.line for r in reports} 18 | expected_errors = { 19 | 755, 20 | 980, 21 | 1230, 22 | 1232, 23 | 1275, 24 | 1281, 25 | 1392, 26 | 1410, 27 | 1416, 28 | 1443, 29 | 1449, 30 | 1572, 31 | 2004, 32 | 2372, 33 | 3459, 34 | 3602, 35 | 3635, 36 | 3758, 37 | 3819, 38 | 4289, 39 | 4304, 40 | 4321, 41 | 4400, 42 | 4408, 43 | 4432, 44 | 4434, 45 | } 46 | undetected_errors = expected_errors - found_errors 47 | assert len(undetected_errors) == 0, \ 48 | f'Undetected errors on line(s) {undetected_errors}' 49 | unexpected_errors = found_errors - expected_errors 50 | assert len(unexpected_errors) == 0, \ 51 | f'Unexpected errors on line(s) {unexpected_errors}' 52 | 53 | 54 | def test_program_1(): 55 | """Test for SQLExec issues and undeclared variables.""" 56 | source_file = ( 57 | os.path.join(_TESTS_DIR, 'PTPG_WORKREC.FUNCLIB.FieldFormula.ppl'), 58 | None, 59 | [(850, 853)] 60 | ) 61 | file_reports = psca.analyze([source_file], _SETTINGS_FILE, 62 | profile='test_02') 63 | reports = file_reports[0].reports 64 | # The elements of the found_errors and expected_errors sets are 65 | # tuples with three elements: (, , ) 66 | found_errors = {(r.rule_code, r.line, r.column) for r in reports} 67 | expected_errors = { 68 | (1, 852, 13), 69 | (2, 850, 16), 70 | (2, 850, 37), 71 | (2, 850, 78), 72 | (2, 850, 95), 73 | (2, 852, 225), 74 | (2, 852, 246), 75 | (2, 852, 288), 76 | (2, 852, 309), 77 | (2, 852, 351), 78 | (2, 852, 372), 79 | (2, 852, 414), 80 | (2, 852, 435), 81 | (2, 852, 477), 82 | (2, 852, 498), 83 | (2, 853, 21), 84 | (2, 853, 42), 85 | } 86 | undetected_errors = expected_errors - found_errors 87 | assert len(undetected_errors) == 0, \ 88 | f'Undetected errors: {undetected_errors}' 89 | unexpected_errors = found_errors - expected_errors 90 | assert len(unexpected_errors) == 0, \ 91 | f'Unexpected errors: {unexpected_errors}' 92 | 93 | 94 | def test_variables(): 95 | """Test for naming convention violations. 96 | 97 | This test is an absurd example for documentation purposes. 98 | """ 99 | source_file = os.path.join(_TESTS_DIR, 'variable_names.ppl') 100 | file_reports = psca.analyze([source_file], _SETTINGS_FILE, 101 | profile='test_03') 102 | reports = file_reports[0].reports 103 | # The elements of the found_errors and expected_errors sets are 104 | # tuples with two elements: (, ) 105 | found_errors = {(r.line, r.column) for r in reports} 106 | expected_errors = { 107 | (1, 14), 108 | (3, 14), 109 | (3, 21), 110 | (3, 28), 111 | (4, 14), 112 | (5, 14), 113 | (7, 15), 114 | (8, 21), 115 | (9, 15), 116 | (9, 28), 117 | } 118 | undetected_errors = expected_errors - found_errors 119 | assert len(undetected_errors) == 0, \ 120 | f'Undetected errors: {undetected_errors}' 121 | unexpected_errors = found_errors - expected_errors 122 | assert len(unexpected_errors) == 0, \ 123 | f'Unexpected errors: {unexpected_errors}' 124 | -------------------------------------------------------------------------------- /tests/test_plain_text.py: -------------------------------------------------------------------------------- 1 | """Plain text analysis tests.""" 2 | 3 | import os.path 4 | import pscodeanalyzer.engine as psca 5 | 6 | 7 | _TESTS_DIR = os.path.dirname(__file__) 8 | _SETTINGS_FILE = os.path.join(_TESTS_DIR, psca.default_config_file_name) 9 | 10 | 11 | def test_plain_text(): 12 | """Test a plain text file for miscellaneous issues.""" 13 | source_file = os.path.join(_TESTS_DIR, 'plain_text_sample.txt') 14 | file_reports = psca.analyze([source_file], _SETTINGS_FILE, 15 | profile='test_04') 16 | reports = file_reports[0].reports 17 | # The elements of the found_errors and expected_errors sets are 18 | # tuples with two elements: (, ) 19 | found_errors = {(r.rule_code, r.line) for r in reports} 20 | expected_errors = { 21 | (4, 7), 22 | (5, None), 23 | (6, 3), 24 | } 25 | undetected_errors = expected_errors - found_errors 26 | assert len(undetected_errors) == 0, \ 27 | f'Undetected errors: {undetected_errors}' 28 | unexpected_errors = found_errors - expected_errors 29 | assert len(unexpected_errors) == 0, \ 30 | f'Unexpected errors: {unexpected_errors}' 31 | -------------------------------------------------------------------------------- /tests/variable_names.ppl: -------------------------------------------------------------------------------- 1 | Local string &var1; 2 | Local string &yo; 3 | Local number &var2, &var3, &var4; 4 | Local string &var5 = "Some value"; 5 | Local number &var6 = (&var2 + &var3) * &var4; 6 | 7 | Local boolean &var7, &yo1; 8 | Local boolean &yo2, &var8; 9 | Local integer &var9, &yo3, &var10; 10 | Local datetime &yo4 = %DateTime; 11 | Local date &yo5 = Date3(1999, 12, 31); 12 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # tox (https://tox.readthedocs.io/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | envlist = py39 8 | 9 | [testenv] 10 | deps = 11 | pytest 12 | peoplecodeparser 13 | commands = 14 | pytest 15 | --------------------------------------------------------------------------------