From f2276d2b5df2c6fce7877ca80762d8b088f19319 Mon Sep 17 00:00:00 2001 From: zanjonke Date: Tue, 23 Jun 2026 11:48:13 +0200 Subject: [PATCH 1/4] fix(parsing): adding check for acceptance test heading --- plain_file.py | 5 ++++ tests/test_plainfileparser.py | 50 +++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/plain_file.py b/plain_file.py index c116d33..21c1b1f 100644 --- a/plain_file.py +++ b/plain_file.py @@ -473,6 +473,11 @@ def parse_plain_source( # noqa: C901 specification_heading = token.children[0].children[0].children[0].content + if specification_heading == plain_spec.ACCEPTANCE_TEST_HEADING: + raise PlainSyntaxError( + f"Plain syntax error: Syntax error at line {token.line_number}: {plain_spec.ACCEPTANCE_TEST_HEADING} heading should be nested under specific functional spec." + ) + if specification_heading not in plain_spec.ALLOWED_SPECIFICATION_HEADINGS: raise PlainSyntaxError( f"Plain syntax error: Syntax error at line {token.line_number}: Invalid specification heading (`{specification_heading}`). Allowed headings: {', '.join(plain_spec.ALLOWED_SPECIFICATION_HEADINGS)}" diff --git a/tests/test_plainfileparser.py b/tests/test_plainfileparser.py index 09f8b5f..9f2f2d3 100644 --- a/tests/test_plainfileparser.py +++ b/tests/test_plainfileparser.py @@ -376,6 +376,56 @@ def test_acceptance_tests_block_include_with_trailing_newline_keeps_structure_an plain_file.plain_file_parser("block_level_include.plain", [get_test_data_path("data/templates")]) +def test_acceptance_tests_top_level_rejected(): + plain_source = """ +***acceptance tests*** + +- Test something. +""" + with pytest.raises( + PlainSyntaxError, + match=re.escape( + "Syntax error at line 1: acceptance tests heading should be nested under specific functional spec." + ), + ): + plain_file.parse_plain_source(plain_source, {}, [], [], []) + + +def test_acceptance_tests_top_level_after_other_headings_rejected(): + plain_source = """***definitions*** + +- :concept: is a concept. + +***acceptance tests*** + +- Test something. +""" + with pytest.raises( + PlainSyntaxError, + match=re.escape( + "Syntax error at line 5: acceptance tests heading should be nested under specific functional spec." + ), + ): + plain_file.parse_plain_source(plain_source, {}, [], [], []) + + +def test_acceptance_tests_nested_under_functional_spec_allowed(): + plain_source = """***definitions*** + +- :concept: is a concept. + +***functional specs*** + +- Display "hello, world" + + ***acceptance tests*** + + - Test the :concept:. +""" + result = plain_file.parse_plain_source(plain_source, {}, [], [], []) + assert plain_spec.FUNCTIONAL_REQUIREMENTS in result.plain_source + + def test_concept_validation_definitions(get_test_data_path): plain_file.plain_file_parser( "concept_validation_definition.plain", From 3972ab4160be6df4f86982461daf153638b66185 Mon Sep 17 00:00:00 2001 From: zanjonke Date: Tue, 23 Jun 2026 11:54:02 +0200 Subject: [PATCH 2/4] fix(parsing): reject acceptance tests nested under non-functional-spec sections --- plain_file.py | 36 ++++++++++++++++++++++ tests/test_plainfileparser.py | 58 +++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/plain_file.py b/plain_file.py index 21c1b1f..4e12684 100644 --- a/plain_file.py +++ b/plain_file.py @@ -203,6 +203,26 @@ def _is_acceptance_test_heading(token) -> tuple[bool, str | None]: return False, None +def _find_acceptance_test_heading(token): + """ + Recursively search a token tree for an `acceptance tests` heading. + + Returns the heading token if one is found anywhere in the subtree, otherwise None. + Used to detect acceptance tests nested under sections other than functional specs. + """ + is_acceptance_test_heading, _ = _is_acceptance_test_heading(token) + if is_acceptance_test_heading: + return token + + if getattr(token, "children", None): + for child in token.children: + found = _find_acceptance_test_heading(child) + if found is not None: + return found + + return None + + def _process_single_acceptance_test_requirement(functional_requirement: mistletoe.block_token.ListItem): """ Process a single functionality to extract acceptance tests. @@ -516,6 +536,22 @@ def parse_plain_source( # noqa: C901 f"Plain syntax error: Syntax error at line {token.line_number}: Invalid source structure (`{token_text}`)" ) + # Acceptance tests are only allowed nested under functional specs. Reject them when + # they are nested under any other section (e.g. definitions, implementation reqs, test reqs). + for non_functional_heading in ( + plain_spec.DEFINITIONS, + plain_spec.NON_FUNCTIONAL_REQUIREMENTS, + plain_spec.TEST_REQUIREMENTS, + ): + section = plain_source.get(non_functional_heading) + if section is None: + continue + nested_acceptance_test_heading = _find_acceptance_test_heading(section) + if nested_acceptance_test_heading is not None: + raise PlainSyntaxError( + f"Plain syntax error: Syntax error at line {nested_acceptance_test_heading.line_number}: {plain_spec.ACCEPTANCE_TEST_HEADING} heading should be nested under specific functional spec." + ) + if plain_source[plain_spec.DEFINITIONS] is not None: with PlainRenderer() as renderer: for token in plain_source[plain_spec.DEFINITIONS].children: diff --git a/tests/test_plainfileparser.py b/tests/test_plainfileparser.py index 9f2f2d3..c42e20d 100644 --- a/tests/test_plainfileparser.py +++ b/tests/test_plainfileparser.py @@ -409,6 +409,64 @@ def test_acceptance_tests_top_level_after_other_headings_rejected(): plain_file.parse_plain_source(plain_source, {}, [], [], []) +def test_acceptance_tests_nested_under_definitions_rejected(): + plain_source = """***definitions*** + +- :concept: is a concept. + + ***acceptance tests*** + + - Test the :concept:. + +***functional specs*** + +- Display "hello, world" +""" + with pytest.raises( + PlainSyntaxError, + match=re.escape( + "Syntax error at line 5: acceptance tests heading should be nested under specific functional spec." + ), + ): + plain_file.parse_plain_source(plain_source, {}, [], [], []) + + +def test_acceptance_tests_nested_under_implementation_reqs_rejected(): + plain_source = """***implementation reqs*** + +- First implementation requirement. + + ***acceptance tests*** + + - Test something. +""" + with pytest.raises( + PlainSyntaxError, + match=re.escape( + "Syntax error at line 5: acceptance tests heading should be nested under specific functional spec." + ), + ): + plain_file.parse_plain_source(plain_source, {}, [], [], []) + + +def test_acceptance_tests_nested_under_test_reqs_rejected(): + plain_source = """***test reqs*** + +- First test requirement. + + ***acceptance tests*** + + - Test something. +""" + with pytest.raises( + PlainSyntaxError, + match=re.escape( + "Syntax error at line 5: acceptance tests heading should be nested under specific functional spec." + ), + ): + plain_file.parse_plain_source(plain_source, {}, [], [], []) + + def test_acceptance_tests_nested_under_functional_spec_allowed(): plain_source = """***definitions*** From 4682d44f1084598a4ff87141995c0fe53030430c Mon Sep 17 00:00:00 2001 From: zanjonke Date: Tue, 23 Jun 2026 14:42:49 +0200 Subject: [PATCH 3/4] feat(parsing): warn when acceptance tests found without conformance tests script Acceptance tests are treated as conformance tests, so a conformance tests script is required to run them. Emit a warning (covering the top module and all required modules) on both the render and --dry-run paths when acceptance tests are present but no conformance tests script is configured. --- plain2code.py | 52 ++++++++-- plain_spec.py | 9 ++ .../main_requiring_acceptance_tests.plain | 16 ++++ .../required_with_acceptance_tests.plain | 15 +++ tests/test_plain2code.py | 94 +++++++++++++++++++ tests/test_plainspec.py | 36 +++++++ 6 files changed, 213 insertions(+), 9 deletions(-) create mode 100644 tests/data/acceptance_tests_warning/main_requiring_acceptance_tests.plain create mode 100644 tests/data/acceptance_tests_warning/required_with_acceptance_tests.plain create mode 100644 tests/test_plain2code.py diff --git a/plain2code.py b/plain2code.py index 6cfc857..8f1a4f4 100644 --- a/plain2code.py +++ b/plain2code.py @@ -158,6 +158,42 @@ def _check_connection(codeplainAPI: codeplain_api.CodeplainAPI): ) +def load_plain_module(args, template_dirs) -> plain_modules.PlainModule: + """Parse the plain file (and its required modules) into a PlainModule.""" + return plain_modules.PlainModule( + args.filename, + args.build_folder, + args.conformance_tests_folder, + template_dirs, + ) + + +def warn_if_acceptance_tests_without_conformance_script(plain_module, args) -> None: + """Warn when any loaded module (including required modules) defines acceptance tests + but no conformance tests script is configured. + + Acceptance tests are treated as conformance tests, so a conformance tests script is required + to actually run them. Without it, the acceptance tests cannot be executed. + """ + if args.conformance_tests_script: + return + + module_names_with_acceptance_tests = [ + module.module_name + for module in plain_module.all_required_modules + [plain_module] + if plain_spec.has_acceptance_tests(module.plain_source) + ] + if not module_names_with_acceptance_tests: + return + + module_names = ", ".join(module_names_with_acceptance_tests) + console.warning( + f"Acceptance tests were found ({module_names}) but no conformance tests script is configured. " + "Acceptance tests are treated as conformance tests and require a conformance tests script " + "(--conformance-tests-script or 'conformance_tests_script' in config) to be executed." + ) + + def render(args, run_state: RunState, event_bus: EventBus, default_log_level: str = "INFO"): # noqa: C901 template_dirs = file_utils.get_template_directories(args.filename, args.template_dir, DEFAULT_TEMPLATE_DIRS) @@ -178,12 +214,9 @@ def render(args, run_state: RunState, event_bus: EventBus, default_log_level: st enter_pause_event = threading.Event() signal.signal(signal.SIGTERM, lambda _signum, _frame: stop_event.set()) - plain_module = plain_modules.PlainModule( - args.filename, - args.build_folder, - args.conformance_tests_folder, - template_dirs, - ) + plain_module = load_plain_module(args, template_dirs) + + warn_if_acceptance_tests_without_conformance_script(plain_module, args) render_choice = None if render_range is None: @@ -323,9 +356,10 @@ def main(): # noqa: C901 if args.dry_run: console.info("Printing dry run output...\n") - _, plain_source_tree, _ = plain_file.plain_file_parser(args.filename, template_dirs) - render_range = plain_spec.compute_render_range(args, plain_source_tree) - print_dry_run_output(plain_source_tree, render_range) + plain_module = load_plain_module(args, template_dirs) + render_range = plain_spec.compute_render_range(args, plain_module.plain_source) + print_dry_run_output(plain_module.plain_source, render_range) + warn_if_acceptance_tests_without_conformance_script(plain_module, args) return except Exception as e: console.error(f"Error: {str(e)}") diff --git a/plain_spec.py b/plain_spec.py index dab505d..003519e 100644 --- a/plain_spec.py +++ b/plain_spec.py @@ -25,6 +25,15 @@ ALLOWED_IMPORT_SPECIFICATION_HEADINGS = [DEFINITIONS, NON_FUNCTIONAL_REQUIREMENTS, TEST_REQUIREMENTS] +def has_acceptance_tests(plain_source_tree) -> bool: + """Return True if any functional requirement in the plain source defines acceptance tests.""" + functional_requirements = plain_source_tree.get(FUNCTIONAL_REQUIREMENTS) or [] + for functional_requirement in functional_requirements: + if functional_requirement.get(ACCEPTANCE_TESTS): + return True + return False + + def collect_specification_linked_resources(specification, specification_heading, linked_resources_list): linked_resources = [] if "linked_resources" in specification: diff --git a/tests/data/acceptance_tests_warning/main_requiring_acceptance_tests.plain b/tests/data/acceptance_tests_warning/main_requiring_acceptance_tests.plain new file mode 100644 index 0000000..7f04d3f --- /dev/null +++ b/tests/data/acceptance_tests_warning/main_requiring_acceptance_tests.plain @@ -0,0 +1,16 @@ +--- +requires: + - required_with_acceptance_tests +--- + +***definitions*** + +- :MainApp: is a console application. + +***implementation reqs*** + +- The :MainApp: should print hello. + +***functional specs*** + +- Display "hello, world" diff --git a/tests/data/acceptance_tests_warning/required_with_acceptance_tests.plain b/tests/data/acceptance_tests_warning/required_with_acceptance_tests.plain new file mode 100644 index 0000000..b6e42fe --- /dev/null +++ b/tests/data/acceptance_tests_warning/required_with_acceptance_tests.plain @@ -0,0 +1,15 @@ +***definitions*** + +- :App: is a console application. + +***implementation reqs*** + +- The :App: should be simple. + +***functional specs*** + +- Implement the entry point for :App:. + + ***acceptance tests*** + + - The entry point should run without errors. diff --git a/tests/test_plain2code.py b/tests/test_plain2code.py new file mode 100644 index 0000000..1a51db4 --- /dev/null +++ b/tests/test_plain2code.py @@ -0,0 +1,94 @@ +import tempfile +from argparse import Namespace +from types import SimpleNamespace +from unittest.mock import patch + +import plain2code +import plain_spec +from plain_modules import PlainModule + + +def _make_module(module_name, has_acceptance_tests, required_modules=None): + """Build a minimal stand-in for a PlainModule. + + The functional requirements only need an `acceptance_tests` key when the module + is expected to contain acceptance tests, because that is all + `plain_spec.has_acceptance_tests` inspects. + """ + functional_requirement = {"markdown": f"- {module_name} functionality."} + if has_acceptance_tests: + functional_requirement[plain_spec.ACCEPTANCE_TESTS] = [{"markdown": "- Test it."}] + + return SimpleNamespace( + module_name=module_name, + plain_source={plain_spec.FUNCTIONAL_REQUIREMENTS: [functional_requirement]}, + all_required_modules=required_modules or [], + ) + + +def test_warns_when_acceptance_tests_present_and_no_conformance_script(): + plain_module = _make_module("top", has_acceptance_tests=True) + args = Namespace(conformance_tests_script=None) + + with patch("plain2code.console") as mock_console: + plain2code.warn_if_acceptance_tests_without_conformance_script(plain_module, args) + + mock_console.warning.assert_called_once() + warning_message = mock_console.warning.call_args.args[0] + assert "top" in warning_message + assert "conformance tests script" in warning_message + + +def test_no_warning_when_conformance_script_configured(): + plain_module = _make_module("top", has_acceptance_tests=True) + args = Namespace(conformance_tests_script="run_conformance_tests.sh") + + with patch("plain2code.console") as mock_console: + plain2code.warn_if_acceptance_tests_without_conformance_script(plain_module, args) + + mock_console.warning.assert_not_called() + + +def test_no_warning_when_no_acceptance_tests(): + plain_module = _make_module("top", has_acceptance_tests=False) + args = Namespace(conformance_tests_script=None) + + with patch("plain2code.console") as mock_console: + plain2code.warn_if_acceptance_tests_without_conformance_script(plain_module, args) + + mock_console.warning.assert_not_called() + + +def test_warns_when_acceptance_tests_only_in_required_module(): + required_module = _make_module("dependency", has_acceptance_tests=True) + plain_module = _make_module("top", has_acceptance_tests=False, required_modules=[required_module]) + args = Namespace(conformance_tests_script=None) + + with patch("plain2code.console") as mock_console: + plain2code.warn_if_acceptance_tests_without_conformance_script(plain_module, args) + + mock_console.warning.assert_called_once() + warning_message = mock_console.warning.call_args.args[0] + assert "dependency" in warning_message + + +def test_warning_covers_required_modules_for_real_plain_module(get_test_data_path): + """Integration test: a main module without acceptance tests that requires a + module with acceptance tests should still trigger the warning, naming the + required module. This mirrors the dry-run path which builds a real PlainModule.""" + fixtures_dir = get_test_data_path("data/acceptance_tests_warning") + with tempfile.TemporaryDirectory() as build, tempfile.TemporaryDirectory() as conformance: + plain_module = PlainModule( + "main_requiring_acceptance_tests.plain", + build, + conformance, + [fixtures_dir], + ) + + args = Namespace(conformance_tests_script=None) + with patch("plain2code.console") as mock_console: + plain2code.warn_if_acceptance_tests_without_conformance_script(plain_module, args) + + mock_console.warning.assert_called_once() + warning_message = mock_console.warning.call_args.args[0] + assert "required_with_acceptance_tests" in warning_message diff --git a/tests/test_plainspec.py b/tests/test_plainspec.py index 83ee4bd..d773870 100644 --- a/tests/test_plainspec.py +++ b/tests/test_plainspec.py @@ -9,6 +9,42 @@ def test_get_linked_resources_invalid_input(): plain_spec.collect_linked_resources([], [], None, True) +def test_has_acceptance_tests_true(): + plain_source = { + plain_spec.FUNCTIONAL_REQUIREMENTS: [ + {"markdown": "- First functionality."}, + { + "markdown": "- Second functionality.", + plain_spec.ACCEPTANCE_TESTS: [{"markdown": "- Test the second functionality."}], + }, + ] + } + assert plain_spec.has_acceptance_tests(plain_source) is True + + +def test_has_acceptance_tests_false_when_no_acceptance_tests(): + plain_source = { + plain_spec.FUNCTIONAL_REQUIREMENTS: [ + {"markdown": "- First functionality."}, + {"markdown": "- Second functionality."}, + ] + } + assert plain_spec.has_acceptance_tests(plain_source) is False + + +def test_has_acceptance_tests_false_when_empty_acceptance_tests(): + plain_source = { + plain_spec.FUNCTIONAL_REQUIREMENTS: [ + {"markdown": "- First functionality.", plain_spec.ACCEPTANCE_TESTS: []}, + ] + } + assert plain_spec.has_acceptance_tests(plain_source) is False + + +def test_has_acceptance_tests_false_when_no_functional_requirements(): + assert plain_spec.has_acceptance_tests({}) is False + + def test_get_frids_simple(get_test_data_path): _, plain_source, _ = plain_file.plain_file_parser("simple.plain", [get_test_data_path("data/")]) From 2d91f43b96301089a8fa69dda8db4b5cdfdbf3c4 Mon Sep 17 00:00:00 2001 From: zanjonke Date: Tue, 23 Jun 2026 14:57:18 +0200 Subject: [PATCH 4/4] refactor: build PlainModule once in main and pass it to render Previously the module was constructed in both the dry-run branch and inside render(), and render() re-parsed the file again just to compute the render range. Parse the plain file once in main() and pass the PlainModule into render(), removing the duplicate construction and the redundant re-parse. --- plain2code.py | 75 +++++++++++++++++++++++++-------------------------- 1 file changed, 37 insertions(+), 38 deletions(-) diff --git a/plain2code.py b/plain2code.py index 8f1a4f4..1f93375 100644 --- a/plain2code.py +++ b/plain2code.py @@ -158,16 +158,6 @@ def _check_connection(codeplainAPI: codeplain_api.CodeplainAPI): ) -def load_plain_module(args, template_dirs) -> plain_modules.PlainModule: - """Parse the plain file (and its required modules) into a PlainModule.""" - return plain_modules.PlainModule( - args.filename, - args.build_folder, - args.conformance_tests_folder, - template_dirs, - ) - - def warn_if_acceptance_tests_without_conformance_script(plain_module, args) -> None: """Warn when any loaded module (including required modules) defines acceptance tests but no conformance tests script is configured. @@ -194,15 +184,17 @@ def warn_if_acceptance_tests_without_conformance_script(plain_module, args) -> N ) -def render(args, run_state: RunState, event_bus: EventBus, default_log_level: str = "INFO"): # noqa: C901 - template_dirs = file_utils.get_template_directories(args.filename, args.template_dir, DEFAULT_TEMPLATE_DIRS) - +def render( # noqa: C901 + plain_module: plain_modules.PlainModule, + args, + run_state: RunState, + event_bus: EventBus, + default_log_level: str = "INFO", +): # Compute render range from either --render-range or --render-from render_range = None if args.render_range or args.render_from: - # Parse the plain file to get the plain_source for FRID extraction - _, plain_source, _ = plain_file.plain_file_parser(args.filename, template_dirs) - render_range = plain_spec.compute_render_range(args, plain_source) + render_range = plain_spec.compute_render_range(args, plain_module.plain_source) codeplainAPI = codeplain_api.CodeplainAPI(args.api_key, console) assert args.api is not None and args.api != "", "API URL is required" @@ -214,8 +206,6 @@ def render(args, run_state: RunState, event_bus: EventBus, default_log_level: st enter_pause_event = threading.Event() signal.signal(signal.SIGTERM, lambda _signum, _frame: stop_event.set()) - plain_module = load_plain_module(args, template_dirs) - warn_if_acceptance_tests_without_conformance_script(plain_module, args) render_choice = None @@ -341,29 +331,38 @@ def main(): # noqa: C901 console.error(f"Error fetching status: {str(e)}") return - # Handle early-exit flags before heavy initialization - if args.dry_run or args.full_plain: - template_dirs = file_utils.get_template_directories(args.filename, args.template_dir, DEFAULT_TEMPLATE_DIRS) + template_dirs = file_utils.get_template_directories(args.filename, args.template_dir, DEFAULT_TEMPLATE_DIRS) + # Handle full plain early-exit (raw text dump; does not require a parsed module). + if args.full_plain: try: - if args.full_plain: - module_name = Path(args.filename).stem - plain_source = plain_file.read_module_plain_source(module_name, template_dirs) - [full_plain_source, _] = file_utils.get_loaded_templates(template_dirs, plain_source) - console.info("Full plain text:\n") - console.info(full_plain_source) - return - - if args.dry_run: - console.info("Printing dry run output...\n") - plain_module = load_plain_module(args, template_dirs) - render_range = plain_spec.compute_render_range(args, plain_module.plain_source) - print_dry_run_output(plain_module.plain_source, render_range) - warn_if_acceptance_tests_without_conformance_script(plain_module, args) - return + module_name = Path(args.filename).stem + plain_source = plain_file.read_module_plain_source(module_name, template_dirs) + [full_plain_source, _] = file_utils.get_loaded_templates(template_dirs, plain_source) + console.info("Full plain text:\n") + console.info(full_plain_source) except Exception as e: console.error(f"Error: {str(e)}") - return + return + + # Parse the plain file (and its required modules) once; reused by dry-run and rendering. + try: + plain_module = plain_modules.PlainModule( + args.filename, + args.build_folder, + args.conformance_tests_folder, + template_dirs, + ) + except Exception as e: + console.error(f"Error: {str(e)}") + return + + if args.dry_run: + console.info("Printing dry run output...\n") + render_range = plain_spec.compute_render_range(args, plain_module.plain_source) + print_dry_run_output(plain_module.plain_source, render_range) + warn_if_acceptance_tests_without_conformance_script(plain_module, args) + return event_bus = EventBus() @@ -391,7 +390,7 @@ def main(): # noqa: C901 raise MissingAPIKey( "Your API key is required. Please set the CODEPLAIN_API_KEY environment variable or provide it with the --api-key argument.\n" ) - render(args, run_state, event_bus, default_log_level) + render(plain_module, args, run_state, event_bus, default_log_level) except BaseException as e: if isinstance(e, KeyboardInterrupt): error_message = "Keyboard interrupt"