Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
c7e244d
build: pin plain-parser to the explicit-test-scenarios branch
NejcS Oct 22, 2026
9b58c03
test: convert the .plain fixtures and examples to the current syntax
NejcS Oct 22, 2026
dbdc708
feat: read test scenarios from the test folders
NejcS Oct 23, 2026
61bc811
feat: take func-ids in --render-range and --render-from
NejcS Oct 23, 2026
785b8ca
feat: track the render state by func-id
NejcS Oct 26, 2026
02ee1c3
test: use func-ids in the git and journal tests
NejcS Oct 26, 2026
092a931
feat: send test scenarios to the backend in the spec tree
NejcS Oct 26, 2026
62412ec
feat: warn when scenarios are found but conformance tests are off
NejcS Oct 26, 2026
d114c5a
feat: name a functionality as module and func-id in the TUI, telemetr…
NejcS Oct 26, 2026
a4d18c4
docs: regenerate the CLI reference from the checkout
NejcS Oct 26, 2026
73bce58
feat: render a functionality without scenarios, without its conforman…
NejcS Oct 27, 2026
4926330
feat: require an explicit test directory when conformance tests are on
NejcS Oct 27, 2026
5b4cc01
fix: bind scenarios once per module and refuse a numbered build folder
NejcS Oct 27, 2026
743413d
test: use func-ids in the render stop reason tests
NejcS Oct 29, 2026
f021773
fix: render the acceptance scenarios that remain after a code fix
NejcS Oct 29, 2026
76dc422
feat: check that the acceptance tests implement the scenarios
NejcS Oct 29, 2026
ec2a4fe
docs: add --job-tag to the CLI reference
NejcS Oct 29, 2026
1bad7b3
fix: render an acceptance scenario once, whatever path asks for it
NejcS Oct 29, 2026
f15373b
fix: write the acceptance tests again when the conformance tests are …
NejcS Oct 29, 2026
aa47895
fix: read the markdown of a required module functionality in the unit…
NejcS Oct 30, 2026
d320a76
style: format the acceptance scenario test with black
NejcS Oct 30, 2026
b1647ae
feat: rename the test folder option to --tests-folder
NejcS Oct 31, 2026
b455488
feat: default the tests folder to "tests" next to the plain file
NejcS Oct 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,8 @@ python plain2code.py path/to/file.plain --log-to-file
python plain2code.py path/to/file.plain --headless

# Render specific functionality range
python plain2code.py file.plain --render-range 1,3 # Render functionalities 1-3
python plain2code.py file.plain --render-from 2 # Resume from functionality 2
python plain2code.py file.plain --render-range add-task,delete-task # Render add-task through delete-task
python plain2code.py file.plain --render-from delete-task # Resume from delete-task

# Run with examples
cd examples/example_hello_world_python
Expand Down Expand Up @@ -136,7 +136,7 @@ black . --check && isort . --check-only && flake8 . && mypy . --check-untyped-de

**Plain Modules**: A `.plain` file can `import` other `.plain` files, creating a module dependency tree. The renderer processes required modules first (depth-first), then the top module.

**Functionalities (FRIDs)**: Each functional requirement in a `.plain` file has a unique ID (FRID). The renderer processes them sequentially. State is checkpointed after each FRID.
**Functionalities (FRIDs)**: Each functional requirement in a `.plain` file has a unique ID (FRID), the func-id of its `# <func-id>` heading. The renderer processes them sequentially. State is checkpointed after each FRID.

**Render State Machine** (`render_machine/states.py`): Hierarchical FSM with states like:
- `IMPLEMENTING_FRID` → `PROCESSING_UNIT_TESTS` → `REFACTORING_CODE` → `PROCESSING_CONFORMANCE_TESTS`
Expand Down
159 changes: 65 additions & 94 deletions change_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,7 @@ def determine_partial_render_start(plain_module: "PlainModule") -> PartialRender
if not changes:
continue

current_fr_count = len(module._get_module_functional_requirements())
earliest_frid = _get_earliest_affected_frid(changes, current_fr_count)
earliest_frid = _get_earliest_affected_frid(changes, _stored_frids(module), _current_frids(module))
if earliest_frid is None:
continue
return PartialRenderStart(module=module, frid=earliest_frid)
Expand All @@ -66,125 +65,97 @@ def _non_functional_content_changed(module: "PlainModule") -> bool:
return stored_hash != module.get_module_non_functional_source_hash()


def _get_earliest_affected_frid(changes: list[FunctionalityChange], current_fr_count: int) -> str | None:
"""Earliest FRID (in current spec numbering) that must be re-rendered.
def _stored_functionalities(module: "PlainModule") -> list[dict[str, str]]:
metadata = module.load_module_metadata()
return metadata.get(MODULE_FUNCTIONALITIES_KEY, []) if metadata else []


def _stored_frids(module: "PlainModule") -> list[str]:
return [functionality["id"] for functionality in _stored_functionalities(module)]


def _current_frids(module: "PlainModule") -> list[str]:
return [functionality["id"] for functionality in module._get_module_functional_requirements()]


Returns the minimum position across all changes:
- added / edited: the FRID itself.
- removed: the position the removal opened up (now occupied by the next FR).
- moved: the FR's old position.
def _get_earliest_affected_frid(
changes: list[FunctionalityChange], old_frids: list[str], new_frids: list[str]
) -> str | None:
"""The func-id of the earliest functionality in the new order that must be re-rendered.

Correctness does not rely on any single change type's position being individually
"the" earliest (in particular, a move's old position is *not* always its earliest
touched position once moves mix with adds/removes). The guarantee is structural:
the first index at which the new spec diverges from the old is always emitted as
some change anchored at that index — a move with that old position, or an
edit/removal/addition there — so the minimum over all change FRIDs lands at or
before the true first divergence. Rendering runs from this FRID to the end of the
module, so an at-or-before start point is always safe (it can re-render unchanged
trailing FRs, but never skips a changed one).
Rendering runs from this functionality to the end of the module, so an at-or-before start
point is always safe: it can re-render unchanged trailing functionalities, but never skips
a changed one.

Returns None when every change is a removal beyond the current spec length
(only trailing FRs were removed, so nothing needs rendering).
Returns None when every change is a removal past the end of the new order (only trailing
functionalities were removed, so nothing needs rendering).
"""
earliest = None
for change in changes:
frid_int = int(change.frid)
if change.change_type == "removed" and frid_int > current_fr_count:
position = _affected_position(change, old_frids, new_frids)
if position is None:
continue
if earliest is None or frid_int < earliest:
earliest = frid_int
if earliest is None or position < earliest:
earliest = position
if earliest is None:
return None
return str(earliest)
return new_frids[earliest]


def _affected_position(change: FunctionalityChange, old_frids: list[str], new_frids: list[str]) -> int | None:
"""Where in the new order the change must be rendered from.

A removed functionality has no place in the new order, so the one that took its place is
rendered instead. A removal past the end of the new order needs no render.
"""
if change.change_type == "removed":
position = old_frids.index(change.frid)
return position if position < len(new_frids) else None

return new_frids.index(change.frid)


def _detect_module_changes(module: "PlainModule") -> list[FunctionalityChange]:
metadata = module.load_module_metadata()
old_frs: list[str] = metadata.get(MODULE_FUNCTIONALITIES_KEY, []) if metadata else []
new_frs: list[str] = module._get_module_functional_requirements()
old_frs = _stored_functionalities(module)
new_frs = module._get_module_functional_requirements()

if old_frs == new_frs:
return []

moves, edits, removed, added = _classify_changes(old_frs, new_frs)
old_frids = [functionality["id"] for functionality in old_frs]
new_frids = [functionality["id"] for functionality in new_frs]
old_markdown = {functionality["id"]: functionality["markdown"] for functionality in old_frs}

changes: list[FunctionalityChange] = []
name = module.module_name

for old_idx, new_idx in moves:
old_frid = _frid_from_index(old_idx)
new_frid = _frid_from_index(new_idx)
changes.append(FunctionalityChange(module=name, frid=old_frid, change_type="moved", detail=new_frid))
for frid in _moved_frids(old_frids, new_frids):
changes.append(FunctionalityChange(module=name, frid=frid, change_type="moved"))

for idx in edits:
changes.append(FunctionalityChange(module=name, frid=_frid_from_index(idx), change_type="edited"))
for functionality in new_frs:
frid = functionality["id"]
if frid in old_markdown and functionality["markdown"] != old_markdown[frid]:
changes.append(FunctionalityChange(module=name, frid=frid, change_type="edited"))

for idx in removed:
changes.append(FunctionalityChange(module=name, frid=_frid_from_index(idx), change_type="removed"))
for frid in old_frids:
if frid not in new_frids:
changes.append(FunctionalityChange(module=name, frid=frid, change_type="removed"))

for idx in added:
changes.append(FunctionalityChange(module=name, frid=_frid_from_index(idx), change_type="added"))
for frid in new_frids:
if frid not in old_markdown:
changes.append(FunctionalityChange(module=name, frid=frid, change_type="added"))

return changes


def _classify_changes(
old_frs: list[str], new_frs: list[str]
) -> tuple[list[tuple[int, int]], list[int], list[int], list[int]]:
matched_old: set[int] = set()
matched_new: set[int] = set()
def _moved_frids(old_frids: list[str], new_frids: list[str]) -> list[str]:
"""The func-ids the two orders keep but place differently, in the new order.

for i in range(min(len(old_frs), len(new_frs))):
if old_frs[i] == new_frs[i]:
matched_old.add(i)
matched_new.add(i)

content_matches: list[tuple[int, int]] = []
for old_idx in range(len(old_frs)):
if old_idx in matched_old:
continue
for new_idx in range(len(new_frs)):
if new_idx in matched_new:
continue
if old_frs[old_idx] == new_frs[new_idx]:
content_matches.append((old_idx, new_idx))
matched_old.add(old_idx)
matched_new.add(new_idx)
break

moves: list[tuple[int, int]] = []
if content_matches and _has_relative_order_change(content_matches):
moves = content_matches

edits: list[int] = []
for i in range(min(len(old_frs), len(new_frs))):
if i not in matched_old and i not in matched_new:
edits.append(i)
matched_old.add(i)
matched_new.add(i)

removed = [i for i in range(len(old_frs)) if i not in matched_old]
added = [i for i in range(len(new_frs)) if i not in matched_new]

return moves, edits, removed, added


def _has_relative_order_change(matches: list[tuple[int, int]]) -> bool:
"""Check if content matches represent a true reorder (relative order changed).

If all matches preserve relative order (sorted by old_idx gives same ordering
as sorted by new_idx), it's just a positional shift from insertions/removals.
Only the functionalities present in both orders are compared, so an addition or a removal
alone shifts no one: it is a move only when the kept functionalities swap places.
"""
if len(matches) <= 1:
return False
sorted_by_old = sorted(matches, key=lambda m: m[0])
new_indices = [m[1] for m in sorted_by_old]
for i in range(len(new_indices) - 1):
if new_indices[i] > new_indices[i + 1]:
return True
return False

kept = set(old_frids) & set(new_frids)
old_order = [frid for frid in old_frids if frid in kept]
new_order = [frid for frid in new_frids if frid in kept]

def _frid_from_index(index: int) -> str:
return str(index + 1)
return [frid for position, frid in enumerate(new_order) if old_order[position] != frid]
10 changes: 7 additions & 3 deletions cli_output/dry_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,13 @@ def print_dry_run_output(plain_source_tree: dict, render_range: Optional[list[st
f"{functional_requirement_text}\n"
"-------------------------------------\n"
)
if plain_spec.ACCEPTANCE_TESTS in specifications:
for i, acceptance_test in enumerate(specifications[plain_spec.ACCEPTANCE_TESTS], 1):
console.info(f"Generating acceptance test #{i}:\n\n{acceptance_test}\n")
for section, label in (
(plain_spec.CONFORMANCE_TESTS, "Conformance scenarios"),
(plain_spec.ACCEPTANCE_TESTS, "Acceptance scenarios"),
):
scenario_ids = [scenario["id"] for scenario in specifications.get(section) or []]
if scenario_ids:
console.info(f"{label}: {', '.join(scenario_ids)}\n")
else:
console.info(
"-------------------------------------\n"
Expand Down
23 changes: 9 additions & 14 deletions codeplain_REST_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ def new_session():
"MissingResource": plain2code_exceptions.MissingResource,
"PlainSyntaxError": plain2code_exceptions.PlainSyntaxError,
"InternalServerError": plain2code_exceptions.InternalServerError,
"OutdatedClient": plain2code_exceptions.OutdatedClientVersion,
}


Expand Down Expand Up @@ -363,7 +364,6 @@ def render_conformance_tests(
required_modules,
conformance_tests_folder_name,
conformance_tests_json,
all_acceptance_tests,
run_state: RunState,
):
endpoint_url = f"{self.api_url}/render_conformance_tests"
Expand All @@ -380,11 +380,10 @@ def render_conformance_tests(
"required_modules": required_modules,
"conformance_tests_folder_name": conformance_tests_folder_name,
"conformance_tests_json": conformance_tests_json,
"all_acceptance_tests": all_acceptance_tests,
}

response = self.post_request(endpoint_url, headers, payload, run_state)
return response["patched_response_files"], response["conformance_tests_plan_summary_string"]
return response["patched_response_files"], response["traceability"]

def generate_folder_name_from_functional_requirement(
self,
Expand Down Expand Up @@ -419,11 +418,9 @@ def fix_conformance_tests_issue(
required_modules,
code_diff,
conformance_tests_files,
acceptance_tests,
conformance_tests_issue,
implementation_fix_count,
conformance_tests_folder_name,
current_testing_frid_high_level_implementation_plan: Optional[str],
conflicting_requirements_count: int,
fix_attempts_journal: list,
run_state: RunState,
Expand All @@ -446,14 +443,10 @@ def fix_conformance_tests_issue(
"conformance_tests_issue": conformance_tests_issue,
"implementation_fix_count": implementation_fix_count,
"conformance_tests_folder_name": conformance_tests_folder_name,
"current_testing_frid_high_level_implementation_plan": current_testing_frid_high_level_implementation_plan,
"conflicting_requirements_count": conflicting_requirements_count,
"fix_attempts_journal": fix_attempts_journal,
}

if acceptance_tests is not None:
payload["acceptance_tests"] = acceptance_tests

return self.post_request(endpoint_url, headers, payload, run_state)

def render_acceptance_tests(
Expand All @@ -466,7 +459,7 @@ def render_acceptance_tests(
conformance_tests_files,
module_name: str,
required_modules,
acceptance_test,
scenario_id,
run_state: RunState,
):
"""
Expand All @@ -484,10 +477,11 @@ def render_acceptance_tests(
conformance_tests_files (dict): A dictionary containing conformance test files.
required_modules (dict): A dictionary where the keys represent module names
and the values are lists of functionalities implemented in those modules.
acceptance_test (dict): A dictionary containing acceptance test information.
scenario_id (str): The scenario-id of the acceptance scenario to render.

Returns:
dict: The rendered acceptance tests.
tuple: The rendered acceptance tests, and the traceability of every scenario of the
functionality a test is due for.

Raises:
Exception: If the request fails or returns an error.
Expand All @@ -504,10 +498,11 @@ def render_acceptance_tests(
"conformance_tests_files": conformance_tests_files,
"module_name": module_name,
"required_modules": required_modules,
"acceptance_test": acceptance_test,
"scenario_id": scenario_id,
}

return self.post_request(endpoint_url, headers, payload, run_state)
response = self.post_request(endpoint_url, headers, payload, run_state)
return response["patched_response_files"], response["traceability"]

def analyze_rendering(
self,
Expand Down
5 changes: 3 additions & 2 deletions docs/generate_cli.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import os
import sys

# Add the parent directory to the path so we can import plain2code_arguments
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Put the checkout first on the path so we read its plain2code_arguments, not an
# installed copy of codeplain that would reflect whatever was last released.
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))


def main():
Expand Down
Loading
Loading