diff --git a/maithili_dsl/transpiler/linter.py b/maithili_dsl/transpiler/linter.py index 03ce3fa..f3cfeef 100644 --- a/maithili_dsl/transpiler/linter.py +++ b/maithili_dsl/transpiler/linter.py @@ -2,6 +2,8 @@ import re +from .transpile import _make_keyword_pattern, _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() @@ -103,7 +111,14 @@ def lint_maithili_code(code, config=LINT_CONFIG): for f in declared_functions: if f == "नव": continue # constructor is called implicitly - used = any(f in line for line in lines if not line.strip().startswith("कार्य")) + call_pattern = re.compile(_make_keyword_pattern(f).pattern + r"\s*\(") + used = any( + call_pattern.search(text) + for line in lines + if not line.strip().startswith("कार्य") + for is_string, text in _tokenize_preserving_strings(line) + if not is_string + ) if not used: errors.append(f"चेतावनी: कार्य '{f}' केहनो ठाम प्रयोग नहि कएल गेल अछि") diff --git a/tests/test_linter.py b/tests/test_linter.py index ab44ac8..d7991cf 100644 --- a/tests/test_linter.py +++ b/tests/test_linter.py @@ -32,6 +32,32 @@ def test_simple_function_with_usage_passes(): assert lint_maithili_code(code) == [] +def test_function_name_inside_longer_identifier_is_unused(): + code = ( + "कार्य जोड़():\n" + " फेर करू १\n" + "\n" + "जोड़ल = ५\n" + ) + + errors = lint_maithili_code(code) + + assert "चेतावनी: कार्य 'जोड़' केहनो ठाम प्रयोग नहि कएल गेल अछि" in errors + + +def test_function_name_inside_string_is_unused(): + code = ( + "कार्य जोड़():\n" + " फेर करू १\n" + "\n" + "छपाउ(\"जोड़()\")\n" + ) + + errors = lint_maithili_code(code) + + assert "चेतावनी: कार्य 'जोड़' केहनो ठाम प्रयोग नहि कएल गेल अछि" in errors + + def test_class_with_constructor_passes(): code = ( "वर्ग व्यक्ति:\n" @@ -221,3 +247,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}"