Skip to content

Commit 03c9b0d

Browse files
store conformance test renders at the module suite root with a two-tier guard
The client now requests module-root-relative output paths (tests in the functionality's subfolder, shared setup files allowed at the suite root) and stores render responses relative to the module's conformance tests folder. Existing-tests context sent to the API now includes shared root files. A two-tier guard validates every response before storing: files in other functionalities' subfolders (or required-module copies) and changes to the definition file are rejected, and existing shared root files may only be extended by inserting lines (difflib opcode check). One retry on violation, then the render fails with a clear error.
1 parent e435de3 commit 03c9b0d

5 files changed

Lines changed: 202 additions & 22 deletions

File tree

‎codeplain_REST_api.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,7 @@ def render_conformance_tests(
335335
"conformance_tests_json": conformance_tests_json,
336336
"all_acceptance_tests": all_acceptance_tests,
337337
"existing_conformance_tests_files": existing_conformance_tests_files,
338+
"conformance_tests_paths_relative_to_module_root": True,
338339
}
339340

340341
response = self.post_request(endpoint_url, headers, payload, run_state)

‎render_machine/actions/render_conformance_tests.py‎

Lines changed: 51 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@
88
from render_machine.actions.base_action import BaseAction
99
from render_machine.implementation_code_helpers import ImplementationCodeHelpers
1010
from render_machine.render_context import RenderContext
11-
from render_machine.render_types import AcceptanceTestPhase, TestExecutionPhase
11+
from render_machine.render_types import AcceptanceTestPhase, RenderError, TestExecutionPhase
1212

1313

1414
class RenderConformanceTests(BaseAction):
1515
SUCCESSFUL_OUTCOME = "conformance_test_rendered"
16+
RESPONSE_VALIDATION_FAILED_OUTCOME = "conformance_test_response_validation_failed"
1617

1718
def execute(self, render_context: RenderContext, _previous_action_payload: Any | None):
1819
if self._should_render_conformance_tests(render_context):
@@ -123,33 +124,64 @@ def _render_conformance_tests(self, render_context: RenderContext):
123124
if not file_name.startswith(current_subfolder_prefix)
124125
}
125126

126-
response_files, implementation_plan_summary = render_context.codeplain_api.render_conformance_tests(
127-
render_context.frid_context.frid,
128-
render_context.conformance_tests_running_context.current_testing_frid,
129-
render_context.plain_source_tree,
130-
render_context.frid_context.linked_resources,
131-
existing_files_content,
132-
memory_files_content,
133-
render_context.module_name,
134-
render_context.get_required_modules_functionalities(),
135-
conformance_tests_folder_name,
136-
render_context.conformance_tests_running_context.get_conformance_tests_json(
137-
render_context.conformance_tests_running_context.current_testing_module_name
138-
),
139-
all_acceptance_tests,
140-
existing_conformance_tests_files,
141-
run_state=render_context.run_state,
127+
current_subfolder_name = os.path.basename(conformance_tests_folder_name)
128+
module_conformance_tests_folder = render_context.conformance_tests.get_module_conformance_tests_folder(
129+
render_context.module_name
142130
)
143131

132+
for attempt in range(2):
133+
response_files, implementation_plan_summary = render_context.codeplain_api.render_conformance_tests(
134+
render_context.frid_context.frid,
135+
render_context.conformance_tests_running_context.current_testing_frid,
136+
render_context.plain_source_tree,
137+
render_context.frid_context.linked_resources,
138+
existing_files_content,
139+
memory_files_content,
140+
render_context.module_name,
141+
render_context.get_required_modules_functionalities(),
142+
conformance_tests_folder_name,
143+
render_context.conformance_tests_running_context.get_conformance_tests_json(
144+
render_context.conformance_tests_running_context.current_testing_module_name
145+
),
146+
all_acceptance_tests,
147+
existing_conformance_tests_files,
148+
run_state=render_context.run_state,
149+
)
150+
151+
violations = render_context.conformance_tests.find_response_file_violations(
152+
render_context.module_name,
153+
current_subfolder_name,
154+
response_files,
155+
)
156+
157+
if not violations:
158+
break
159+
160+
console.warning(
161+
"Generated conformance test files violate the suite layout rules:\n "
162+
+ "\n ".join(violations)
163+
+ ("\nRetrying the generation." if attempt == 0 else "")
164+
)
165+
else:
166+
return (
167+
self.RESPONSE_VALIDATION_FAILED_OUTCOME,
168+
RenderError.encode(
169+
message="Generated conformance test files repeatedly violated the suite layout rules "
170+
"(files outside the functionality's subfolder or invalid changes to shared setup files).",
171+
error_type="CONFORMANCE_TESTS_VALIDATION_ERROR",
172+
violations="\n".join(violations),
173+
).to_payload(),
174+
)
175+
144176
render_context.conformance_tests_running_context.current_testing_frid_high_level_implementation_plan = (
145177
implementation_plan_summary
146178
)
147179

148-
file_utils.store_response_files(conformance_tests_folder_name, response_files, [])
180+
file_utils.store_response_files(module_conformance_tests_folder, response_files, [])
149181

150182
console.print_files(
151183
"Conformance test files generated:",
152-
conformance_tests_folder_name,
184+
module_conformance_tests_folder,
153185
response_files,
154186
style=console.OUTPUT_STYLE,
155187
)

‎render_machine/conformance_tests.py‎

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import difflib
12
import json
23
import os
34

@@ -9,6 +10,18 @@
910
CONFORMANCE_TESTS_DEFINITION_FILE_NAME = "conformance_tests.json"
1011

1112

13+
def _is_insertion_only_change(existing_content: str, new_content: str) -> bool:
14+
"""Check that new_content only inserts lines into existing_content.
15+
16+
Every existing line must survive, in order - the diff opcodes may contain only
17+
"equal" and "insert" operations.
18+
"""
19+
matcher = difflib.SequenceMatcher(
20+
None, existing_content.splitlines(keepends=True), new_content.splitlines(keepends=True), autojunk=False
21+
)
22+
return all(opcode in ("equal", "insert") for opcode, *_ in matcher.get_opcodes())
23+
24+
1225
class ConformanceTests:
1326
"""Manages the state of conformance tests."""
1427

@@ -177,9 +190,10 @@ def fetch_all_existing_conformance_test_files(self, module_name: str) -> dict[st
177190
178191
Files are collected from every conformance test subfolder of the module (one subfolder
179192
per functional requirement) and keyed as "<subfolder>/<relative file path>" so each
180-
file's suite remains identifiable. Hidden subfolders (copies of required modules' tests)
181-
and the conformance tests definition file (stored at the module folder root) are not
182-
included. Returns an empty dict when the module has no conformance tests yet.
193+
file's suite remains identifiable, plus any shared setup files at the module folder
194+
root (keyed by their bare file name). Hidden subfolders (copies of required modules'
195+
tests) and the conformance tests definition file are not included. Returns an empty
196+
dict when the module has no conformance tests yet.
183197
"""
184198
all_files_content: dict[str, str] = {}
185199
module_folder = self.get_module_conformance_tests_folder(module_name)
@@ -190,8 +204,66 @@ def fetch_all_existing_conformance_test_files(self, module_name: str) -> dict[st
190204
for file_name, content in files_content.items():
191205
all_files_content[os.path.join(folder_name, file_name)] = content
192206

207+
if os.path.isdir(module_folder):
208+
root_file_names = [
209+
entry.name
210+
for entry in os.scandir(module_folder)
211+
if entry.is_file() and entry.name != self.conformance_tests_definition_file_name
212+
]
213+
root_files_content = file_utils.get_existing_files_content(module_folder, sorted(root_file_names))
214+
all_files_content.update(root_files_content)
215+
193216
return all_files_content
194217

218+
def find_response_file_violations(
219+
self,
220+
module_name: str,
221+
current_subfolder_name: str,
222+
response_files: dict[str, str],
223+
) -> list[str]:
224+
"""Check a conformance tests render response against the two-tier immutability rule.
225+
226+
Paths are relative to the module's conformance tests folder. Allowed: any file under
227+
the current functionality's subfolder, new files anywhere outside other
228+
functionalities' subfolders, and extensions of existing shared setup files at the
229+
suite root that only insert lines. Violations: files in other functionalities'
230+
subfolders (or required-module copies), the conformance tests definition file, and
231+
root-file changes that delete or modify existing lines.
232+
"""
233+
violations = []
234+
module_folder = self.get_module_conformance_tests_folder(module_name)
235+
known_suite_folders = set(self.fetch_existing_conformance_test_folder_names(module_name))
236+
237+
for file_name, content in response_files.items():
238+
path_parts = file_name.replace(os.sep, "/").split("/")
239+
top_level_name = path_parts[0]
240+
241+
if top_level_name == current_subfolder_name:
242+
continue
243+
244+
if len(path_parts) > 1:
245+
if top_level_name.startswith("."):
246+
violations.append(f"{file_name}: files of required modules' test copies must not be changed")
247+
elif top_level_name in known_suite_folders:
248+
violations.append(f"{file_name}: belongs to another functionality's test suite")
249+
continue
250+
251+
if file_name == self.conformance_tests_definition_file_name:
252+
violations.append(f"{file_name}: the conformance tests definition file must not be changed")
253+
continue
254+
255+
existing_file_path = os.path.join(module_folder, file_name)
256+
if os.path.exists(existing_file_path):
257+
with open(existing_file_path, "r") as f:
258+
existing_content = f.read()
259+
if content is None or not _is_insertion_only_change(existing_content, content):
260+
violations.append(
261+
f"{file_name}: shared setup files may only be extended by adding lines - "
262+
"existing lines must not be changed or removed"
263+
)
264+
265+
return violations
266+
195267
def fetch_existing_conformance_test_files(
196268
self,
197269
module_name: str,

‎render_machine/state_machine_config.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ def get_action_result_triggers_map(self) -> Dict[str, str]:
8585
FinishFunctionalRequirement.SUCCESSFUL_OUTCOME: triggers.PROCEED_FRID_PROCESSING,
8686
CreateDist.SUCCESSFUL_OUTCOME: triggers.FINISH_RENDER,
8787
RenderConformanceTests.SUCCESSFUL_OUTCOME: triggers.MARK_CONFORMANCE_TESTS_READY,
88+
RenderConformanceTests.RESPONSE_VALIDATION_FAILED_OUTCOME: triggers.HANDLE_ERROR,
8889
PrepareTestingEnvironment.SUCCESSFUL_OUTCOME: triggers.MARK_TESTING_ENVIRONMENT_PREPARED,
8990
PrepareTestingEnvironment.FAILED_OUTCOME: triggers.HANDLE_ERROR,
9091
RunConformanceTests.SUCCESSFUL_OUTCOME: triggers.MOVE_TO_NEXT_CONFORMANCE_TEST,

‎tests/test_conformance_tests.py‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,77 @@ def test_get_module_suite_run_folder_required_module_without_copy(conformance_te
106106
)
107107

108108
assert folder == os.path.join(conformance_tests_dir, "required_module")
109+
110+
111+
def test_fetch_all_existing_conformance_test_files_includes_shared_root_files(conformance_tests, conformance_tests_dir):
112+
module_folder = os.path.join(conformance_tests_dir, MODULE_NAME)
113+
_write_file(module_folder, os.path.join("some_functionality", "test_some.py"), "some test")
114+
_write_file(module_folder, "shared_helpers.py", "shared helper")
115+
_write_file(module_folder, CONFORMANCE_TESTS_DEFINITION_FILE_NAME, json.dumps({}))
116+
117+
files_content = conformance_tests.fetch_all_existing_conformance_test_files(MODULE_NAME)
118+
119+
assert files_content == {
120+
os.path.join("some_functionality", "test_some.py"): "some test",
121+
"shared_helpers.py": "shared helper",
122+
}
123+
124+
125+
def test_find_response_file_violations_allows_own_subfolder_and_new_root_files(
126+
conformance_tests, conformance_tests_dir
127+
):
128+
module_folder = os.path.join(conformance_tests_dir, MODULE_NAME)
129+
_write_file(module_folder, os.path.join("earlier_suite", "test_earlier.py"), "earlier")
130+
131+
violations = conformance_tests.find_response_file_violations(
132+
MODULE_NAME,
133+
"current_suite",
134+
{
135+
os.path.join("current_suite", "test_new.py"): "new test",
136+
"shared_helpers.py": "brand new helper",
137+
os.path.join("support", "util.py"): "new shared dir file",
138+
},
139+
)
140+
141+
assert violations == []
142+
143+
144+
def test_find_response_file_violations_rejects_other_suites_and_hidden_copies(conformance_tests, conformance_tests_dir):
145+
module_folder = os.path.join(conformance_tests_dir, MODULE_NAME)
146+
_write_file(module_folder, os.path.join("earlier_suite", "test_earlier.py"), "earlier")
147+
148+
violations = conformance_tests.find_response_file_violations(
149+
MODULE_NAME,
150+
"current_suite",
151+
{
152+
os.path.join("earlier_suite", "test_earlier.py"): "rewritten",
153+
os.path.join(".required_module", "suite", "test_x.py"): "copied",
154+
CONFORMANCE_TESTS_DEFINITION_FILE_NAME: "{}",
155+
},
156+
)
157+
158+
assert len(violations) == 3
159+
160+
161+
def test_find_response_file_violations_shared_root_file_insertion_only(conformance_tests, conformance_tests_dir):
162+
module_folder = os.path.join(conformance_tests_dir, MODULE_NAME)
163+
_write_file(module_folder, "shared_helpers.py", "line one\nline two\n")
164+
165+
extended = "line one\nnew line in between\nline two\nnew line at end\n"
166+
assert (
167+
conformance_tests.find_response_file_violations(MODULE_NAME, "current_suite", {"shared_helpers.py": extended})
168+
== []
169+
)
170+
171+
modified = "line one CHANGED\nline two\n"
172+
violations = conformance_tests.find_response_file_violations(
173+
MODULE_NAME, "current_suite", {"shared_helpers.py": modified}
174+
)
175+
assert len(violations) == 1
176+
assert "adding lines" in violations[0]
177+
178+
truncated = "line one\n"
179+
violations = conformance_tests.find_response_file_violations(
180+
MODULE_NAME, "current_suite", {"shared_helpers.py": truncated}
181+
)
182+
assert len(violations) == 1

0 commit comments

Comments
 (0)