Full Content
TITLE: NSG Linter
#!/usr/bin/env python3
# CHANGE LOG
# File: nsg_linter.py
# Purpose: Check code files against NSG standards
# Dependencies: None (standard library only)
# Usage: python nsg_linter.py [directory]
# Version History:
# 2025-01-02 v1.0 - Initial creation
import os
import re
import sys
from pathlib import Path
from datetime import datetime
# File length limits
LIMITS = {
'.py': {'soft': 400, 'hard': 600},
'.js': {'soft': 300, 'hard': 500},
'.html': {'soft': 200, 'hard': 300},
'.css': {'soft': 200, 'hard': 400},
}
# Directories to skip
SKIP_DIRS = {'venv', 'env', '.git', '__pycache__', 'node_modules', 'build', 'dist'}
def count_lines(filepath):
try:
with open(filepath, 'r', encoding='utf-8') as f:
return len(f.readlines())
except Exception as e:
print(f"ERROR: count_lines - Cannot read {filepath}: {e}")
return 0
def check_changelog(filepath, ext):
try:
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read(2000)
if ext == '.py':
if '# CHANGE LOG' in content or '# File:' in content:
return True
elif ext == '.html':
if 'CHANGE LOG' in content or 'File:' in content:
return True
elif ext in ['.js', '.css']:
if 'CHANGE LOG' in content or 'File:' in content:
return True
return False
except Exception as e:
print(f"ERROR: check_changelog - Cannot read {filepath}: {e}")
return False
def check_python_classes(filepath):
classes_found = []
try:
with open(filepath, 'r', encoding='utf-8') as f:
lines = f.readlines()
for i, line in enumerate(lines, 1):
if re.match(r'^class\s+\w+', line.strip()):
classes_found.append(i)
return classes_found
except Exception as e:
print(f"ERROR: check_python_classes - Cannot read {filepath}: {e}")
return []
def check_python_try_except(filepath):
functions_without_try = []
try:
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
lines = content.split('\n')
in_function = False
current_function = None
current_function_line = 0
has_try = False
indent_level = 0
for i, line in enumerate(lines, 1):
stripped = line.strip()
if stripped.startswith('def ') and '(' in stripped:
if in_function and not has_try and current_function:
if not current_function.startswith('_'):
functions_without_try.append((current_function_line, current_function))
match = re.match(r'def\s+(\w+)\s*\(', stripped)
if match:
current_function = match.group(1)
current_function_line = i
in_function = True
has_try = False
indent_level = len(line) - len(line.lstrip())
elif in_function and stripped.startswith('try:'):
has_try = True
if in_function and not has_try and current_function:
if not current_function.startswith('_'):
functions_without_try.append((current_function_line, current_function))
return functions_without_try
except Exception as e:
print(f"ERROR: check_python_try_except - Cannot read {filepath}: {e}")
return []
def check_jinja_in_comments(filepath):
violations = []
try:
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
in_comment = False
lines = content.split('\n')
for i, line in enumerate(lines, 1):
if '<!--' in line:
in_comment = True
if in_comment:
if re.search(r'\{%.*%\}', line) or re.search(r'\{\{.*\}\}', line):
violations.append(i)
if '-->' in line:
in_comment = False
return violations
except Exception as e:
print(f"ERROR: check_jinja_in_comments - Cannot read {filepath}: {e}")
return []
def check_debug_error_prints(filepath):
functions_without_prints = []
try:
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
function_pattern = r'def\s+(\w+)\s*\([^)]*\):'
matches = list(re.finditer(function_pattern, content))
for i, match in enumerate(matches):
func_name = match.group(1)
if func_name.startswith('_'):
continue
start = match.end()
if i + 1 < len(matches):
end = matches[i + 1].start()
else:
end = len(content)
func_body = content[start:end]
has_except = 'except' in func_body
if has_except:
has_error_print = 'print(f"ERROR:' in func_body or "print(f'ERROR:" in func_body or 'print("ERROR:' in func_body
if not has_error_print:
line_num = content[:match.start()].count('\n') + 1
functions_without_prints.append((line_num, func_name))
return functions_without_prints
except Exception as e:
print(f"ERROR: check_debug_error_prints - Cannot read {filepath}: {e}")
return []
def lint_file(filepath):
issues = []
ext = Path(filepath).suffix.lower()
if ext not in LIMITS:
return issues
line_count = count_lines(filepath)
soft = LIMITS[ext]['soft']
hard = LIMITS[ext]['hard']
if line_count > hard:
issues.append(f"ERROR: {line_count} lines exceeds hard limit of {hard}")
elif line_count > soft:
issues.append(f"WARNING: {line_count} lines exceeds soft limit of {soft}")
if not check_changelog(filepath, ext):
issues.append("ERROR: Missing change log header")
if ext == '.py':
classes = check_python_classes(filepath)
for line_num in classes:
issues.append(f"WARNING: Class definition at line {line_num} (no-classes rule)")
funcs_no_try = check_python_try_except(filepath)
for line_num, func_name in funcs_no_try:
issues.append(f"WARNING: Function '{func_name}' at line {line_num} has no try/except")
funcs_no_print = check_debug_error_prints(filepath)
for line_num, func_name in funcs_no_print:
issues.append(f"WARNING: Function '{func_name}' at line {line_num} missing ERROR: print in except block")
if ext == '.html':
jinja_violations = check_jinja_in_comments(filepath)
for line_num in jinja_violations:
issues.append(f"ERROR: Jinja syntax in HTML comment at line {line_num}")
return issues
def lint_directory(directory):
print("=" * 60)
print(f"NSG LINTER - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"Scanning: {directory}")
print("=" * 60)
print()
total_files = 0
files_with_issues = 0
total_errors = 0
total_warnings = 0
for root, dirs, files in os.walk(directory):
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
for filename in files:
ext = Path(filename).suffix.lower()
if ext not in LIMITS:
continue
filepath = os.path.join(root, filename)
relative_path = os.path.relpath(filepath, directory)
issues = lint_file(filepath)
total_files += 1
if issues:
files_with_issues += 1
print(f"--- {relative_path} ---")
for issue in issues:
print(f" {issue}")
if issue.startswith("ERROR"):
total_errors += 1
elif issue.startswith("WARNING"):
total_warnings += 1
print()
print("=" * 60)
print("SUMMARY")
print("=" * 60)
print(f"Files scanned: {total_files}")
print(f"Files with issues: {files_with_issues}")
print(f"Total errors: {total_errors}")
print(f"Total warnings: {total_warnings}")
print()
if total_errors > 0:
print("RESULT: FAIL (errors found)")
return 1
elif total_warnings > 0:
print("RESULT: PASS with warnings")
return 0
else:
print("RESULT: PASS")
return 0
def main():
if len(sys.argv) > 1:
directory = sys.argv[1]
else:
directory = '.'
if not os.path.isdir(directory):
print(f"ERROR: {directory} is not a valid directory")
sys.exit(1)
result = lint_directory(directory)
sys.exit(result)
if __name__ == '__main__':
main()