From d775d0e1bd14ef29308fcf25afc5499c9fef4588 Mon Sep 17 00:00:00 2001 From: Ana Date: Wed, 28 Oct 2026 17:26:36 +0100 Subject: [PATCH 1/4] fix: treat a scalar frontmatter list key as a list of one A single value under import or requires was iterated character by character, so `import: lib` looked for modules l, i and b. These keys and the two concept keys now accept a single value as a list of one, and a non-string module name raises PlainSyntaxError. --- src/plain_parser/plain_file.py | 15 ++++++---- tests/data/imports/scalar_import/lib.plain | 3 ++ tests/data/imports/scalar_import/main.plain | 11 ++++++++ .../imports/scalar_import_single_char/l.plain | 3 ++ .../scalar_import_single_char/main.plain | 11 ++++++++ .../data/requires/scalar_requires_main.plain | 11 ++++++++ tests/test_imports.py | 28 ++++++++++++++++++- tests/test_requires.py | 6 ++++ 8 files changed, 81 insertions(+), 7 deletions(-) create mode 100644 tests/data/imports/scalar_import/lib.plain create mode 100644 tests/data/imports/scalar_import/main.plain create mode 100644 tests/data/imports/scalar_import_single_char/l.plain create mode 100644 tests/data/imports/scalar_import_single_char/main.plain create mode 100644 tests/data/requires/scalar_requires_main.plain diff --git a/src/plain_parser/plain_file.py b/src/plain_parser/plain_file.py index c4ce1c0..ddff89a 100644 --- a/src/plain_parser/plain_file.py +++ b/src/plain_parser/plain_file.py @@ -448,9 +448,6 @@ def process_imports( if REQUIRED_CONCEPTS_DIRECTIVE in plain_file_parse_result.plain_source_obj.metadata: for item in plain_file_parse_result.plain_source_obj.metadata[REQUIRED_CONCEPTS_DIRECTIVE]: - assert isinstance( - item, str - ), f"Syntax error: Invalid {REQUIRED_CONCEPTS_DIRECTIVE} metadata. Expected a string." new_concepts, _ = concept_utils.extract_concepts_from_definition(item) required_concepts.extend(new_concepts) @@ -480,11 +477,17 @@ def read_plain_source_metadata(plain_source_text): except Exception as e: raise PlainSyntaxError(f"Plain syntax error: Invalid frontmatter: {e}") + for directive in [IMPORT_DIRECTIVE, REQUIRES_DIRECTIVE, EXPORTED_CONCEPTS_DIRECTIVE, REQUIRED_CONCEPTS_DIRECTIVE]: + if directive in plain_source_obj.metadata and not isinstance(plain_source_obj.metadata[directive], list): + plain_source_obj.metadata[directive] = [plain_source_obj.metadata[directive]] + + for directive in [IMPORT_DIRECTIVE, REQUIRES_DIRECTIVE]: + for module_name in plain_source_obj.metadata.get(directive, []): + if not isinstance(module_name, str): + raise PlainSyntaxError(f"Plain syntax error: Invalid {directive} metadata. Expected a module name.") + for directive in [EXPORTED_CONCEPTS_DIRECTIVE, REQUIRED_CONCEPTS_DIRECTIVE]: if directive in plain_source_obj.metadata: - assert isinstance( - plain_source_obj.metadata[directive], list - ), f"Syntax error: Invalid {directive} metadata. Expected a list." prepared_metadata = [] for item in plain_source_obj.metadata[directive]: if isinstance(item, dict): diff --git a/tests/data/imports/scalar_import/lib.plain b/tests/data/imports/scalar_import/lib.plain new file mode 100644 index 0000000..2edf6f5 --- /dev/null +++ b/tests/data/imports/scalar_import/lib.plain @@ -0,0 +1,3 @@ +***implementation reqs*** + +- The main executable file should be called "hello_world.py". diff --git a/tests/data/imports/scalar_import/main.plain b/tests/data/imports/scalar_import/main.plain new file mode 100644 index 0000000..357257d --- /dev/null +++ b/tests/data/imports/scalar_import/main.plain @@ -0,0 +1,11 @@ +--- +import: lib +--- + +***implementation reqs*** + +- The main executable file should be called "hello_world.py". + +***functional specs*** + +- Display "hello, world" diff --git a/tests/data/imports/scalar_import_single_char/l.plain b/tests/data/imports/scalar_import_single_char/l.plain new file mode 100644 index 0000000..2edf6f5 --- /dev/null +++ b/tests/data/imports/scalar_import_single_char/l.plain @@ -0,0 +1,3 @@ +***implementation reqs*** + +- The main executable file should be called "hello_world.py". diff --git a/tests/data/imports/scalar_import_single_char/main.plain b/tests/data/imports/scalar_import_single_char/main.plain new file mode 100644 index 0000000..3429bea --- /dev/null +++ b/tests/data/imports/scalar_import_single_char/main.plain @@ -0,0 +1,11 @@ +--- +import: l +--- + +***implementation reqs*** + +- The main executable file should be called "hello_world.py". + +***functional specs*** + +- Display "hello, world" diff --git a/tests/data/requires/scalar_requires_main.plain b/tests/data/requires/scalar_requires_main.plain new file mode 100644 index 0000000..dd0d24d --- /dev/null +++ b/tests/data/requires/scalar_requires_main.plain @@ -0,0 +1,11 @@ +--- +requires: chain_base +--- + +***implementation reqs*** + +- Scalar requires implementation requirement. + +***functional specs*** + +- A scalar requires functionality. diff --git a/tests/test_imports.py b/tests/test_imports.py index 83816da..7017890 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -1,7 +1,7 @@ import pytest from plain_parser import plain_file -from plain_parser.exceptions import ImportedModuleWithFunctionalitiesError +from plain_parser.exceptions import ImportedModuleWithFunctionalitiesError, PlainSyntaxError def test_imported_module_with_functionalities(load_test_data, get_test_data_path): @@ -51,3 +51,29 @@ def test_diamond_imports(load_test_data, get_test_data_path): ], "functional specs": [{"markdown": '- Display "hello, world"'}], } + + +@pytest.mark.parametrize("fixture", ["scalar_import", "scalar_import_single_char"]) +def test_scalar_import_is_a_list_of_one(load_test_data, get_test_data_path, fixture): + plain_source_text = load_test_data(f"data/imports/{fixture}/main.plain") + template_dirs = [get_test_data_path(f"data/imports/{fixture}")] + plain_file_parse_result = plain_file.parse_plain_source(plain_source_text, {}, template_dirs, [], []) + + assert plain_file.marshall_plain_source(plain_file_parse_result.plain_source)["implementation reqs"] == [ + {"markdown": '- The main executable file should be called "hello_world.py".'}, + {"markdown": '- The main executable file should be called "hello_world.py".'}, + ] + + +@pytest.mark.parametrize("directive", ["import", "requires"]) +def test_non_string_module_entry_is_a_syntax_error(directive): + plain_source_text = f"---\n{directive}: [1]\n---\n\n***functional specs***\n\n- Display text\n" + with pytest.raises(PlainSyntaxError, match=f"Invalid {directive} metadata"): + plain_file.parse_plain_source(plain_source_text, {}, [], [], []) + + +@pytest.mark.parametrize("directive", ["exported_concepts", "required_concepts"]) +def test_scalar_concept_directive_is_a_list_of_one(directive): + plain_source_obj = plain_file.read_plain_source_metadata(f'---\n{directive}: ":A:"\n---\n') + + assert plain_source_obj.metadata[directive] == ["- :A:"] diff --git a/tests/test_requires.py b/tests/test_requires.py index 0166b01..e434b73 100644 --- a/tests/test_requires.py +++ b/tests/test_requires.py @@ -37,6 +37,12 @@ def test_parse_module_chain_orders_required_modules_first(get_test_data_path): assert "functional specs" in plain_source_tree +def test_scalar_requires_is_a_list_of_one(get_test_data_path): + chain = plain_file.parse_module_chain("scalar_requires_main.plain", [get_test_data_path("data/requires")]) + + assert [module_name for module_name, _ in chain] == ["chain_base", "scalar_requires_main"] + + def test_parse_module_chain_single_module(get_test_data_path): chain = plain_file.parse_module_chain("chain_base.plain", [get_test_data_path("data/requires")]) From c43a63c6ca8e5d9e79579ec22f92f698dc761b71 Mon Sep 17 00:00:00 2001 From: Ana Date: Wed, 28 Oct 2026 17:53:30 +0100 Subject: [PATCH 2/4] test: cover a non-string entry under a concept key --- tests/test_imports.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_imports.py b/tests/test_imports.py index 7017890..9d1b492 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -77,3 +77,9 @@ def test_scalar_concept_directive_is_a_list_of_one(directive): plain_source_obj = plain_file.read_plain_source_metadata(f'---\n{directive}: ":A:"\n---\n') assert plain_source_obj.metadata[directive] == ["- :A:"] + + +@pytest.mark.parametrize("directive", ["exported_concepts", "required_concepts"]) +def test_non_string_concept_entry_is_a_syntax_error(directive): + with pytest.raises(PlainSyntaxError, match=f"Invalid {directive} metadata"): + plain_file.read_plain_source_metadata(f"---\n{directive}: [1]\n---\n") From 441b56025f861973a5af756e460044d87d6e2771 Mon Sep 17 00:00:00 2001 From: Ana Date: Wed, 28 Oct 2026 18:22:02 +0100 Subject: [PATCH 3/4] fix: reject a required_concepts entry without a concept token An imported module's required_concepts entries were read with their extraction errors discarded, so an entry such as `foo` added no requirement and the import passed. It now raises PlainSyntaxError, as an invalid exported_concepts entry already does. --- src/plain_parser/plain_file.py | 7 ++++++- tests/test_imports.py | 10 ++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/plain_parser/plain_file.py b/src/plain_parser/plain_file.py index ddff89a..ff15f77 100644 --- a/src/plain_parser/plain_file.py +++ b/src/plain_parser/plain_file.py @@ -448,7 +448,12 @@ def process_imports( if REQUIRED_CONCEPTS_DIRECTIVE in plain_file_parse_result.plain_source_obj.metadata: for item in plain_file_parse_result.plain_source_obj.metadata[REQUIRED_CONCEPTS_DIRECTIVE]: - new_concepts, _ = concept_utils.extract_concepts_from_definition(item) + new_concepts, errors = concept_utils.extract_concepts_from_definition(item) + if errors: + raise PlainSyntaxError( + f"Plain syntax error: Module '{module_name}' requires an invalid concept (`{item}`). " + f"Expected a concept token, e.g. :ConceptName:." + ) required_concepts.extend(new_concepts) required_concepts.extend(plain_file_parse_result.required_concepts) diff --git a/tests/test_imports.py b/tests/test_imports.py index 9d1b492..6d94e9a 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -83,3 +83,13 @@ def test_scalar_concept_directive_is_a_list_of_one(directive): def test_non_string_concept_entry_is_a_syntax_error(directive): with pytest.raises(PlainSyntaxError, match=f"Invalid {directive} metadata"): plain_file.read_plain_source_metadata(f"---\n{directive}: [1]\n---\n") + + +@pytest.mark.parametrize("value", ["foo", "[foo]"]) +def test_imported_required_concept_without_concept_token_is_a_syntax_error(tmp_path, value): + (tmp_path / "lib.plain").write_text(f"---\nrequired_concepts: {value}\n---\n\n***implementation reqs***\n\n- x\n") + plain_source_text = ( + "---\nimport: [lib]\n---\n\n***implementation reqs***\n\n- y\n\n***functional specs***\n\n- Display text\n" + ) + with pytest.raises(PlainSyntaxError, match="requires an invalid concept"): + plain_file.parse_plain_source(plain_source_text, {}, [str(tmp_path)], [], []) From 04d676607294736fd28afc4e62b464e0a52f92ff Mon Sep 17 00:00:00 2001 From: Ana Date: Wed, 28 Oct 2026 18:34:24 +0100 Subject: [PATCH 4/4] test: cover unquoted concept tokens in a frontmatter block list --- tests/test_imports.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/test_imports.py b/tests/test_imports.py index 6d94e9a..f350848 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -1,6 +1,6 @@ import pytest -from plain_parser import plain_file +from plain_parser import concept_utils, plain_file from plain_parser.exceptions import ImportedModuleWithFunctionalitiesError, PlainSyntaxError @@ -93,3 +93,13 @@ def test_imported_required_concept_without_concept_token_is_a_syntax_error(tmp_p ) with pytest.raises(PlainSyntaxError, match="requires an invalid concept"): plain_file.parse_plain_source(plain_source_text, {}, [str(tmp_path)], [], []) + + +@pytest.mark.parametrize("directive", ["exported_concepts", "required_concepts"]) +@pytest.mark.parametrize("entry, concepts", [(":A:", [":A:"]), (":A:, :B:", [":A:", ":B:"])]) +def test_unquoted_concept_in_block_list(directive, entry, concepts): + # YAML reads an unquoted `- :A:` as a mapping; real modules rely on it still naming :A:. + plain_source_obj = plain_file.read_plain_source_metadata(f"---\n{directive}:\n - {entry}\n---\n") + + [item] = plain_source_obj.metadata[directive] + assert concept_utils.extract_concepts_from_definition(item) == (concepts, [])