Repository: oscal-compass/compliance-trestle

Issue:
get_control_response missing "This System" component
## Describe the bug

For controls with statements, the "This System" implementation statements are missing from the individual parts.

This appears to be a [regression introduced](https://github.com/oscal-compass/compliance-trestle/pull/1358/files?diff=unified&w=0#diff-0543d4fc8151f036968d940bbe821cd67cb5640cacd78fb22f8709439247f4c6L262-L264) in #1358 

## To Reproduce

Steps to reproduce the behavior:

1. Create a system security plan with a control with multiple parts (such as SC-7)
1. Use `trestle author jinja` in combination with `ssp_md_writer.get_control_response` to create a markdown SSPP
1. See that the implementation statements for each individual part are missing for the `This System` component (though they'll be present for any other components)

## Expected behavior

I expected that `This System` implementation statements are included for all parts of the control.

## Screenshots / Logs.

If applicable, add screenshots to help explain your problem.

## Environment

- OS: \[e.g. iOS\] Docker/Debian
- Python version: 3.11.11
- Installed packages: compliance-trestle 3.8.0

Hints from issue comments:

Solution patch:
diff --git a/trestle/core/ssp_io.py b/trestle/core/ssp_io.py
index f938bba8..f398ae3c 100644
--- a/trestle/core/ssp_io.py
+++ b/trestle/core/ssp_io.py
@@ -17,8 +17,7 @@ import pathlib
 from typing import Dict, List, Optional, Tuple
 
 from trestle.common.common_types import TypeWithByComps
-from trestle.common.const import CONTROL_ORIGINATION, IMPLEMENTATION_STATUS, ITEM, SSP_MAIN_COMP_NAME
-from trestle.common.const import STATUS_OPERATIONAL
+from trestle.common.const import CONTROL_ORIGINATION, IMPLEMENTATION_STATUS, ITEM
 from trestle.common.err import TrestleError
 from trestle.common.list_utils import as_list
 from trestle.core.catalog import catalog_interface
@@ -219,20 +218,23 @@ class SSPMarkdownWriter():
         rules: List[str],
         status: str,
         show_rules: bool,
-        show_status: bool
+        show_status: bool,
+        show_comp: bool,
+        level: int
     ) -> None:
-        header = f'Component: {comp_name}'
-        md_writer.new_header(1, header)
+        if comp_name and show_comp:
+            header = f'Component: {comp_name}'
+            md_writer.new_header(level, header)
         md_writer.set_indent_level(-1)
         md_writer.new_line(prose)
         md_writer.set_indent_level(-1)
         if rules and show_rules:
-            md_writer.new_header(2, title='Rules:')
+            md_writer.new_header((level + 1), title='Rules:')
             md_writer.set_indent_level(-1)
             md_writer.new_list(rules)
             md_writer.set_indent_level(-1)
         if status and show_status:
-            md_writer.new_header(2, title=f'Implementation Status: {status}')
+            md_writer.new_header((level + 1), title=f'Implementation Status: {status}')
 
     def get_control_response(
         self,
@@ -267,21 +269,16 @@ class SSPMarkdownWriter():
 
         md_writer = MDWriter(None)
 
-        system_prose = ''
-        system_rules = []
-        system_status = STATUS_OPERATIONAL
+        # write out top-level implementation statements, whether or not this control has parts
         imp_req_responses = self._get_responses_by_components(imp_req, write_empty_responses)
-        if SSP_MAIN_COMP_NAME in imp_req_responses:
-            system_prose, system_rules, system_status = imp_req_responses[SSP_MAIN_COMP_NAME]
-
-        SSPMarkdownWriter._write_component_prompt(
-            md_writer, SSP_MAIN_COMP_NAME, system_prose, system_rules, system_status, show_rules, show_status
-        )
-
-        # if a control has no statement sub-parts then get the response bycomps from the imp_req itself
-        # otherwise get them from the statements in the imp_req
-        # an imp_req and a statement are both things that can have bycomps
-        has_bycomps = imp_req.statements if imp_req.statements else [imp_req]
+        for comp_name, comp_response in imp_req_responses.items():
+            prose, rules, status = comp_response
+            SSPMarkdownWriter._write_component_prompt(
+                md_writer, comp_name, prose, rules, status, show_rules, show_status, show_comp, 1
+            )
+
+        # iterate over statements, if present, to write out each part
+        has_bycomps = imp_req.statements if imp_req.statements else []
         for has_bycomp in has_bycomps:
             statement_id = getattr(has_bycomp, 'statement_id', f'{control_id}_smt')
             label = statement_id
@@ -302,13 +299,10 @@ class SSPMarkdownWriter():
                     header = f'Implementation for part {label}'
                     md_writer.new_header(1, title=header)
                 for comp_name, comp_response in response_per_component.items():
-                    if comp_name == SSP_MAIN_COMP_NAME:
-                        continue
                     prose, rules, status = comp_response
-                    if show_comp:
-                        SSPMarkdownWriter._write_component_prompt(
-                            md_writer, comp_name, prose, rules, status, show_rules, show_status
-                        )
+                    SSPMarkdownWriter._write_component_prompt(
+                        md_writer, comp_name, prose, rules, status, show_rules, show_status, show_comp, 2
+                    )
 
         lines = md_writer.get_lines()
 
@@ -339,7 +333,7 @@ class SSPMarkdownWriter():
                 status = by_comp.implementation_status.state
             rules, _ = ControlInterface.get_rule_list_for_item(by_comp)
 
-            if prose or (not prose and write_empty_responses):
+            if prose or write_empty_responses:
                 if subheader:
                     response_per_component[subheader] = (prose, rules, status)
 


Test patch:
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 5f583b82..68864fa6 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -485,7 +485,8 @@ def setup_for_ssp(
     output_name: str,
     use_yaml: bool = False,
     leveraged_ssp_name: str = '',
-    comp_names='comp_def_a,comp_def_b'
+    comp_names='comp_def_a,comp_def_b',
+    include_all_parts: bool = False
 ) -> Tuple[argparse.Namespace, pathlib.Path]:
     """Create the comp_def, profile and catalog content needed for ssp-generate."""
     for comp_name in comp_names.split(','):
@@ -508,7 +509,7 @@ def setup_for_ssp(
         output=output_name,
         verbose=0,
         overwrite_header_values=False,
-        include_all_parts=False,
+        include_all_parts=include_all_parts,
         yaml_header=yaml_path,
         allowed_sections=None,
         force_overwrite=None
diff --git a/tests/trestle/core/ssp_io_test.py b/tests/trestle/core/ssp_io_test.py
index dc0a6973..b9a6f97c 100644
--- a/tests/trestle/core/ssp_io_test.py
+++ b/tests/trestle/core/ssp_io_test.py
@@ -90,7 +90,7 @@ def test_ssp_writer(testdata_dir: pathlib.Path, tmp_trestle_dir: pathlib.Path, m
 
 def test_ssp_get_control_response(tmp_trestle_dir: pathlib.Path, monkeypatch: MonkeyPatch) -> None:
     """Test generating SSP from the sample profile and generate markdown representation of it."""
-    args, _ = setup_for_ssp(tmp_trestle_dir, prof_name, ssp_name)
+    args, _ = setup_for_ssp(tmp_trestle_dir, prof_name, ssp_name, include_all_parts=True)
     ssp_cmd = SSPGenerate()
     assert ssp_cmd._run(args) == 0
 
@@ -119,14 +119,20 @@ def test_ssp_get_control_response(tmp_trestle_dir: pathlib.Path, monkeypatch: Mo
     assert md_text
     tree = DocsMarkdownNode.build_tree_from_markdown(md_text.split('\n'))
 
-    assert tree.get_node_for_key('## Implementation for part a.')
-    assert len(list(tree.get_all_headers_for_level(2))) == 4
-    assert len(list(tree.get_all_headers_for_level(3))) == 5
+    assert len(list(tree.get_all_headers_for_level(2))) == 6  # top level This, aa, ab + 3 part labels
+
+    part_a = tree.get_node_for_key('## Implementation for part a.')
+    assert part_a
+    assert len(list(part_a.get_all_headers_for_level(3))) == 3  # should be 3 components: This, aa, bb
+    assert len(list(part_a.get_all_headers_for_level(4))) == 5  # should be 3 statuses + 2 rules
 
     md_text = ssp_io.get_control_response('ac-3', 1, True)
     assert md_text
     tree = DocsMarkdownNode.build_tree_from_markdown(md_text.split('\n'))
 
+    assert len(list(tree.get_all_headers_for_level(2))) == 2  # should be 2 components: This, aa
+    assert len(list(tree.get_all_headers_for_level(3))) == 2  # should be 2 statuses
+
     # change responses
     new_imp_prose = 'edited imp req prose'
     new_a_prose = 'edited a prose'
@@ -146,12 +152,12 @@ def test_ssp_get_control_response(tmp_trestle_dir: pathlib.Path, monkeypatch: Mo
     # confirm edited response is there and that comp name presence is controlled
     md_text = ssp_io.get_control_response('ac-1', 1, False, False)
     assert 'comp_aa' not in md_text
-    assert new_a_prose not in md_text
+    assert new_a_prose in md_text
 
     md_text = ssp_io.get_control_response('ac-1', 1, False, True)
     assert 'comp_aa' in md_text
     assert new_a_prose in md_text
-    assert '\n### Implementation Status: partial' in md_text
+    assert '\n#### Implementation Status: partial' in md_text
 
 
 def test_writers(testdata_dir: pathlib.Path, tmp_trestle_dir: pathlib.Path, monkeypatch: MonkeyPatch) -> None: