diff --git a/.gitignore b/.gitignore index 56e6ac6..c067b8d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,11 @@ docs *.pyc *.egg-info .venv/ +venv/ build dist +launch.json +settings.json # EMACS ignores *~ diff --git a/CHANGELOG.md b/CHANGELOG.md index 7faf77a..5c5bdd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,10 @@ generated in the following situations: * [TRLC] Fix an UnboundLocalError when missing a term in an expression. +* Adds the feature of checking for duplicated records + in `*.trlc` and `*.rsl` files. This led to mistakenly + overwrite already set attribute's value. + ### 2.0.0 This new major release includes a number of incompatible diff --git a/Makefile b/Makefile index 062cc6e..834504b 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: docs test style lint package +.PHONY: docs test style lint package squash-commits lint: style @python3 -m pylint --rcfile=pylint3.cfg \ @@ -111,3 +111,12 @@ tracing: report.lobster mkdir -p docs lobster-html-report report.lobster --out=docs/tracing.html lobster-ci-report report.lobster + +clean-coverage: + @rm -rf htmlcov + @find . -name '.coverage*' -type f -delete + @find . -name '*.pyc' -type f -delete + @echo "All .coverage, .coverage.* and *.pyc files deleted." + +squash-commits: + util/squash_commits.sh diff --git a/documentation/LRM.md b/documentation/LRM.md index fec560b..15a7265 100644 --- a/documentation/LRM.md +++ b/documentation/LRM.md @@ -1,6 +1,7 @@ # TRLC LRM -* [Version 3.0](https://bmw-software-engineering.github.io/trlc/lrm-3.0.html) (Current Stable) +* [Version 3.1](https://bmw-software-engineering.github.io/trlc/lrm.html) (Current Stable) +* [Version 3.0](https://bmw-software-engineering.github.io/trlc/lrm-3.0.html) * [Version 2.9](https://bmw-software-engineering.github.io/trlc/lrm-2.9.html) * [Version 2.8](https://bmw-software-engineering.github.io/trlc/lrm-2.8.html) * [Version 2.7](https://bmw-software-engineering.github.io/trlc/lrm-2.7.html) diff --git a/language-reference-manual/lrm.trlc b/language-reference-manual/lrm.trlc index 043d41c..7401d27 100644 --- a/language-reference-manual/lrm.trlc +++ b/language-reference-manual/lrm.trlc @@ -2,7 +2,7 @@ package LRM Versioning Version { major = 3 - minor = 0 + minor = 1 } GFDL_License License { @@ -1775,6 +1775,11 @@ section "Record object declarations" { references to R.)*''' } +Dynamic_Semantics Single_Value_Assignment { + text = '''A value can be assigned to a component of a record object + only once.''' +} + Note Tuple_Checks { text = '''The checks for a tuple aggregate are immediately evaluated after the last value is parsed.''' diff --git a/tests-system/rbt-single-value-assignment/foo.rsl b/tests-system/rbt-single-value-assignment/foo.rsl new file mode 100644 index 0000000..40a7659 --- /dev/null +++ b/tests-system/rbt-single-value-assignment/foo.rsl @@ -0,0 +1,6 @@ +package Example + +type Requirement { + description String + critical Boolean +} diff --git a/tests-system/rbt-single-value-assignment/foo.trlc b/tests-system/rbt-single-value-assignment/foo.trlc new file mode 100644 index 0000000..8fdd4f3 --- /dev/null +++ b/tests-system/rbt-single-value-assignment/foo.trlc @@ -0,0 +1,23 @@ +package Example + +Requirement shape { + description = "Our car shall have four wheels." + critical = false +} + +Requirement safety { + critical = false + description = "Our car shall not explode." + critical = true +} + +Requirement form { + description = "Our car should look nice." + critical = false +} + +Requirement duplicated { + description = "This is fine" + critical = true + description = "This is not fine anymore" +} diff --git a/tests-system/rbt-single-value-assignment/output b/tests-system/rbt-single-value-assignment/output new file mode 100644 index 0000000..5b3bae7 --- /dev/null +++ b/tests-system/rbt-single-value-assignment/output @@ -0,0 +1,5 @@ +critical = true +^^^^^^^^ rbt-single-value-assignment/foo.trlc:11: error: component 'critical' already assigned at line 9 +description = "This is not fine anymore" +^^^^^^^^^^^ rbt-single-value-assignment/foo.trlc:22: error: component 'description' already assigned at line 20 +Processed 1 model and 1 requirement file and found 2 errors diff --git a/tests-system/rbt-single-value-assignment/output.brief b/tests-system/rbt-single-value-assignment/output.brief new file mode 100644 index 0000000..cf20013 --- /dev/null +++ b/tests-system/rbt-single-value-assignment/output.brief @@ -0,0 +1,2 @@ +rbt-single-value-assignment/foo.trlc:11:3: trlc error: component 'critical' already assigned at line 9 +rbt-single-value-assignment/foo.trlc:22:3: trlc error: component 'description' already assigned at line 20 diff --git a/tests-system/rbt-single-value-assignment/output.json b/tests-system/rbt-single-value-assignment/output.json new file mode 100644 index 0000000..5b3bae7 --- /dev/null +++ b/tests-system/rbt-single-value-assignment/output.json @@ -0,0 +1,5 @@ +critical = true +^^^^^^^^ rbt-single-value-assignment/foo.trlc:11: error: component 'critical' already assigned at line 9 +description = "This is not fine anymore" +^^^^^^^^^^^ rbt-single-value-assignment/foo.trlc:22: error: component 'description' already assigned at line 20 +Processed 1 model and 1 requirement file and found 2 errors diff --git a/tests-system/rbt-single-value-assignment/output.smtlib b/tests-system/rbt-single-value-assignment/output.smtlib new file mode 100644 index 0000000..5b3bae7 --- /dev/null +++ b/tests-system/rbt-single-value-assignment/output.smtlib @@ -0,0 +1,5 @@ +critical = true +^^^^^^^^ rbt-single-value-assignment/foo.trlc:11: error: component 'critical' already assigned at line 9 +description = "This is not fine anymore" +^^^^^^^^^^^ rbt-single-value-assignment/foo.trlc:22: error: component 'description' already assigned at line 20 +Processed 1 model and 1 requirement file and found 2 errors diff --git a/tests-unit/test_ast_bysection.py b/tests-unit/test_ast_bysection.py index afdcc70..d97b465 100644 --- a/tests-unit/test_ast_bysection.py +++ b/tests-unit/test_ast_bysection.py @@ -1,6 +1,8 @@ import unittest -from unittest.mock import patch, MagicMock -from trlc.ast import Symbol_Table +from unittest.mock import MagicMock, patch +from trlc.ast import (Record_Object, Implicit_Null, Literal, Record_Type, + Package, Composite_Type, Composite_Component, Symbol_Table) +from trlc.errors import Location class TestRecordObject: def __init__(self, location, section): @@ -38,3 +40,125 @@ def test_iter_record_objects_by_section(self, mock_iter_record_objects): if __name__ == '__main__': unittest.main() + +""" +class TestCompositeType(Composite_Type): + def __init__(self): + super().__init__(name="mock_composite_type", + location=Location(file_name="mock_file", line_no=1, col_no=1), + description="mock description") + self.components = {} + + +class TestCompositeComponent(Composite_Component): + def __init__(self, name): + member_of = TestCompositeType() + super().__init__(name=name, + description="test_description", + location=Location(file_name="mock_file", line_no=1, col_no=1), + member_of=member_of, + n_typ=None, + optional=False) + + +class TestLiteral(Literal): + def __init__(self): + location = Location(file_name="test_file", line_no=1, col_no=1) + super().__init__(location=location, typ=None) + + def can_be_null(self): + return False + + def dump(self, indent=0): + pass + + def to_python_object(self): + return "test_literal_value" + + def to_string(self): + return "test_literal" + + +class TestRecordObjectMethods(unittest.TestCase): + def setUp(self): + name = "mock_name" + location = Location(file_name="mock_file", line_no=1, col_no=1) + builtin_stab = Symbol_Table() + declared_late = False + package = Package( + name="mock_package", + location=location, + builtin_stab=builtin_stab, + declared_late=declared_late, + ) + description = "mock_description" + n_parent = None + is_abstract = False + n_typ = Record_Type( + name=name, + description=description, + location=location, + package=package, + n_parent=n_parent, + is_abstract=is_abstract, + ) + section = None + n_package = package + self.record = Record_Object( + name=name, + location=location, + n_typ=n_typ, + section=section, + n_package=n_package, + ) + self.record.field = {} + + def test_is_component_implicit_null_uninitialized(self): + # Intention is to check if it correctly identifies an uninitialized component like a + # component with an Implicit_Null value in the field dictionary. + + component = TestCompositeComponent("test_component") + self.record.field[component.name] = Implicit_Null(self.record, component) + self.assertTrue(self.record.is_component_implicit_null(component)) + + def test_is_component_implicit_null_initialized(self): + # Intention is to verify that it returns False for initialized components + # like when a valid value has been assigned already. + component = TestCompositeComponent("test_component") + self.record.field[component.name] = TestLiteral() + self.assertFalse(self.record.is_component_implicit_null(component)) + + def test_assign_uninitialized_component(self): + # Intention is to test whether the assign method correctly assigns a value + # to a component that is currently uninitialized (set to Implicit_Null). + + component = TestCompositeComponent("new_component") + value = TestLiteral() + self.record.field[component.name] = Implicit_Null(self.record, component) + self.record.assign(component, value) + self.assertEqual(self.record.field[component.name], value) + + def test_assign_duplicate_component(self): + # Intention is to confirm that the assign method raises an error + # when attempting to assign a value to a component that has already been assigned a value. + component = TestCompositeComponent("duplicate_component") + initial_value = TestLiteral() + self.record.field[component.name] = initial_value + new_value = TestLiteral() + with self.assertRaises(KeyError): + self.record.assign(component, new_value) + +if __name__ == '__main__': + unittest.main() + + + +The testing by using mocking requires instantiating many classes of the AST the because of how the assertions at runtime check and verify certain conditions about the arguments. If those conditions aren't satisfied perfectly, the code fails immediately, even if the behavior being tested is unrelated to the failed assertion. + +Assertions in methods like `assign` in `ast.py` (arround line 3000) check for specific types and states of dependencies (e.g., ensuring arguments are instances of Composite_Component, Record_Object, or Implicit_Null). This means one must carefully construct the exact objects expected by the assertions everytime, which involves setting up a chain of related objects and probbably mocking again even more dependencies. The `assert` statements enforce all dependencies perfectly to be configured. + +In order to achieve this 'perfectness' within the assertions some test classes like TestLiteral, TestCompositeComponent, and TestCompositeType were created purely as 'dummy' classes, allowing to bypass the complex setups required for the actual classes (Literal, Composite_Component, Composite_Type). + +Propposed solution for testing: Adopt System-Level Testing + - Instead of testing individual methods in isolation, test the system's behavior as a whole using realistic input files or configurations and comparing against a expected output of the system given specific inputs, rather than low-level internal states. +""" diff --git a/trlc/ast.py b/trlc/ast.py index d36900c..d1f16ce 100644 --- a/trlc/ast.py +++ b/trlc/ast.py @@ -2999,6 +2999,9 @@ def to_python_dict(self): return {name: value.to_python_object() for name, value in self.field.items()} + def is_component_implicit_null(self, component) -> bool: + return not isinstance(self.field[component.name], Implicit_Null) + def assign(self, component, value): assert isinstance(component, Composite_Component) assert isinstance(value, (Literal, @@ -3008,6 +3011,9 @@ def assign(self, component, value): Implicit_Null, Unary_Expression)), \ "value is %s" % value.__class__.__name__ + if self.is_component_implicit_null(component): + raise KeyError(f"Component {component.name} already \ + assigned to {self.n_typ.name} {self.name}!") self.field[component.name] = value def dump(self, indent=0): # pragma: no cover diff --git a/trlc/parser.py b/trlc/parser.py index c227ee0..cf74ddd 100644 --- a/trlc/parser.py +++ b/trlc/parser.py @@ -1784,6 +1784,7 @@ def parse_record_object_declaration(self): # lobster-trace: LRM.Valid_Enumeration_Literals # lobster-trace: LRM.Mandatory_Components # lobster-trace: LRM.Evaluation_Of_Checks + # lobster-trace: LRM.Single_Value_Assignment r_typ = self.parse_qualified_name(self.default_scope, ast.Record_Type) @@ -1810,6 +1811,11 @@ def parse_record_object_declaration(self): comp = r_typ.components.lookup(self.mh, self.ct, ast.Composite_Component) + if obj.is_component_implicit_null(comp): + self.mh.error(self.ct.location, + "component '%s' already assigned at line %i" % + (comp.name, + obj.field[comp.name].location.line_no)) comp.set_ast_link(self.ct) if r_typ.is_frozen(comp): self.mh.error(self.ct.location, diff --git a/util/squash_commits.sh b/util/squash_commits.sh new file mode 100755 index 0000000..298fd3e --- /dev/null +++ b/util/squash_commits.sh @@ -0,0 +1,63 @@ +#!/bin/bash + +set -e # Exit immediately if any command exits with a non-zero status + +# Function: check if the environment is clean +# checks for unstagged (diff --quiet) or stagged but uncommited changes (git diff --cached --quiet) +check_clean_worktree() { + if ! git diff --quiet || ! git diff --cached --quiet; then + echo "Error: Uncommitted changes detected. Please commit or stash changes first." + exit 1 + fi +} + +fetch_and_rebase() { + CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) + git fetch origin + echo "Checking if the branch is up to date with 'origin/$CURRENT_BRANCH'..." + if ! git diff --quiet "$CURRENT_BRANCH" "origin/$CURRENT_BRANCH"; then + echo "Branch is out of date. Rebasing onto 'origin/$CURRENT_BRANCH'..." + git rebase "origin/$CURRENT_BRANCH" || handle_rebase_conflicts + fi +} + +handle_rebase_conflicts() { + while ! git rebase --continue 2>/dev/null; do + echo "Resolve conflicts, stage the changes, and reattempting 'git rebase --continue'..." + read -p "Press ENTER after resolving conflicts and staging changes." + done +} + +# Squash commits interactively +interactive_squash() { + echo "Starting interactive rebase to squash commits into one..." + git rebase -i origin/main || handle_rebase_conflicts +} + +# Function to force push changes after confirmation +force_push_with_confirmation() { + read -p "Do you want to force push the changes to the remote branch? (yes/no): " RESPONSE + case "$RESPONSE" in + [Yy][Ee][Ss]|[Yy]) + CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) + echo "Force pushing changes to 'origin/$CURRENT_BRANCH'..." + git push --force-with-lease + echo "Changes successfully pushed to remote." + ;; + *) + echo "Force push canceled. Your changes are local only." + ;; + esac +} + +# Main script execution +main() { + check_clean_worktree + fetch_and_rebase + interactive_squash + echo "Rebase and squash completed successfully." + force_push_with_confirmation +} + +# Entry point +main \ No newline at end of file