diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a6d508..b0cd61b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- Parentheses and quote characters inside string literals no longer trigger false unbalanced-linter warnings. +- Single-quote balance is now checked alongside double-quote balance (previously only double quotes were validated). - Augmented assignments no longer produce invalid-variable-name linter errors. ### Added diff --git a/maithili_dsl/transpiler/linter.py b/maithili_dsl/transpiler/linter.py index 03ce3fa..49b66da 100644 --- a/maithili_dsl/transpiler/linter.py +++ b/maithili_dsl/transpiler/linter.py @@ -2,6 +2,8 @@ import re +from .transpile import _tokenize_preserving_strings + LINT_CONFIG = { "enforce_snake_case": False, "max_line_length": 80, @@ -44,12 +46,18 @@ def lint_maithili_code(code, config=LINT_CONFIG): if "=" in stripped and stripped.startswith("="): errors.append(f"पंक्ति {i}: '=' चिह्नक पहिले कोनो मान अपेक्षित अछि") - # Check for unbalanced parentheses or quotes - if stripped.count("(") != stripped.count(")"): + # Check for unbalanced parentheses or quotes. + # Tokenize the line and count only in code regions so that + # parens and quotes inside string literals are not counted. + tokens = _tokenize_preserving_strings(line) + code_parts = [text for is_str, text in tokens if not is_str] + code_line = "".join(code_parts) + if code_line.count("(") != code_line.count(")"): errors.append(f"पंक्ति {i}: गोल ब्रैकेट असंतुलित अछि") - if stripped.count("\"") % 2 != 0: + if code_line.count("\"") % 2 != 0: + errors.append(f"पंक्ति {i}: उद्धरण चिह्न जोड़ा में नहि अछि") + if code_line.count("'") % 2 != 0: errors.append(f"पंक्ति {i}: उद्धरण चिह्न जोड़ा में नहि अछि") - # Check for indentation (must start with 4-space or tab if inside block) if line and not line.startswith(" ") and not stripped.endswith(":") and i > 1: prev = lines[i-2].strip() diff --git a/tests/test_linter.py b/tests/test_linter.py index ab44ac8..7202188 100644 --- a/tests/test_linter.py +++ b/tests/test_linter.py @@ -221,3 +221,57 @@ class CustomError(Exception): out = translate_exception_to_maithili(CustomError("demo")) assert "CustomError" in out + +# --------------------------------------------------------------------------- +# Regression: parens and quotes inside string literals (issue #30) +# --------------------------------------------------------------------------- + + +def test_parens_inside_double_quoted_string_not_flagged(): + code = 'छपाउ("(क")\n' + errors = lint_maithili_code(code) + assert not any( + "गोल ब्रैकेट" in e + for e in errors + ), f"parens inside double-quoted string falsely flagged: {errors}" + + +def test_parens_inside_single_quoted_string_not_flagged(): + code = "छपाउ('(क')\n" + errors = lint_maithili_code(code) + assert not any( + "गोल ब्रैकेट" in e + for e in errors + ), f"parens inside single-quoted string falsely flagged: {errors}" + + +def test_quote_inside_string_not_flagged(): + code = 'छपाउ("it\'s ok")\n' + assert "it's ok" in code + errors = lint_maithili_code(code) + assert not any("उद्धरण" in e for e in errors), ( + f"apostrophe in double-quoted string falsely flagged: {errors}" + ) + + +def test_unbalanced_single_quote_flagged(): + code = "छपाउ('hi)\n" + errors = lint_maithili_code(code) + assert any("उद्धरण" in e for e in errors), ( + f"unbalanced single quote not flagged: {errors}" + ) + + +def test_genuinely_unbalanced_parens_still_flagged(): + errors = lint_maithili_code("छपाउ(x\n") + assert any( + "गोल ब्रैकेट" in e + for e in errors + ), f"genuinely unbalanced parens not flagged: {errors}" + + +def test_genuinely_unbalanced_quotes_still_flagged(): + errors = lint_maithili_code('छपाउ("hi)\n') + assert any("उद्धरण" in e for e in errors), ( + f"genuinely unbalanced quotes not flagged: {errors}" + ) diff --git a/tests/test_security.py b/tests/test_security.py index 1b6787e..8620de5 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -251,3 +251,43 @@ def test_dangerous_module_not_in_whitelist(dangerous): assert dangerous not in _ALLOWED_MODULES, ( f"{dangerous} must never be in the import whitelist" ) + +# --------------------------------------------------------------------------- +# Security regression: linter tokenizer does not widen the trust boundary +# --------------------------------------------------------------------------- + + +@pytest.mark.security +def test_linter_tokenizer_does_not_expand_trust_boundary(): + """Code-like content inside string literals remains data. + + The linter now uses _tokenize_preserving_strings for + paren/quote balance. This test verifies that using the + tokenizer in the lint path does not accidentally weaken + any runtime security boundaries — the transpiler, import + validator, and sandbox execution path are unchanged. + """ + from maithili_dsl.transpiler.linter import lint_maithili_code + + code_inside_string = '\u091b\u092a\u093e\u0909("import os; exec()")\n' + errors = lint_maithili_code(code_inside_string) + assert not any( + "\u0917\u094b\u0932 \u092c\u094d\u0930\u0948\u0915\u0947\u091f" in e + for e in errors + ) + + +@pytest.mark.security +def test_actual_dangerous_code_outside_strings_still_blocked(): + """Raw Python import outside strings is still caught by the import validator. + + The linter change uses the tokenizer for paren/quote balance checks. + This test verifies that the security model is unchanged: dangerous + code outside string literals remains blocked by _validate_imports. + """ + from maithili_dsl.transpiler.transpile import transpile_maithili_code + + code = '\u091b\u092a\u093e\u0909("safe")\nimport os\n' + transpiled = transpile_maithili_code(code) + errors = _validate_imports(transpiled, translated_modules=set()) + assert errors, f"raw 'import os' must be blocked by import validator: {errors}"