diff --git a/changelog.d/8k-signature-statement.fixed.md b/changelog.d/8k-signature-statement.fixed.md new file mode 100644 index 000000000..7245b5079 --- /dev/null +++ b/changelog.d/8k-signature-statement.fixed.md @@ -0,0 +1 @@ +**An 8-K with no SIGNATURES heading no longer folds its signature block into the last item.** When the block opens straight with "Pursuant to the requirements of the Securities Exchange Act of 1934 ... caused this report to be signed", that sentence now ends the item. In a 90-document 8-K sample this cleaned the last item of 3 filings and changed nothing else. diff --git a/edgar/documents/extractors/pattern_section_extractor.py b/edgar/documents/extractors/pattern_section_extractor.py index 01853c1e4..00b1e1ded 100644 --- a/edgar/documents/extractors/pattern_section_extractor.py +++ b/edgar/documents/extractors/pattern_section_extractor.py @@ -33,6 +33,16 @@ # officers appear below" is prose, not a boundary. Same test Strategy 5b uses. _SIGNATURES_HEADER = re.compile(r'^\s*SIGNATURES?\s*$', re.IGNORECASE) +# The sentence Form 8-K's General Instructions prescribe to open the signature +# block, matched on whitespace-normalized paragraph text. Anchored at the start +# and required to reach "caused this report to be signed", so a paragraph that +# merely cites the Exchange Act is not a boundary. Strategy 5c. +_SIGNATURE_STATEMENT = re.compile( + r'^Pursuant to the requirements of the Securities Exchange Act of 1934,?' + r'.{0,80}?caused this (?:report|amendment) to be signed', + re.IGNORECASE, +) + # An item's own SUB-header: an item number, one or more parenthesized # sub-designations, and nothing else — "Item 14(a)(1):", "Item 14 (a)(2):". # Filings that itemize under Regulation S-K's lettered sub-paragraphs write @@ -974,8 +984,38 @@ def _has_bold_descendant(n) -> bool: continue headers.append((node, stripped, position)) existing_positions.add(position) + has_sig_header = True break # one SIGNATURES header is enough + # Strategy 5c: no SIGNATURES line at all. Some 8-Ks open the signature + # block straight with the statutory sentence ("Pursuant to the + # requirements of the Securities Exchange Act of 1934, the registrant + # has duly caused this report to be signed ..."), so without a + # boundary the block ran on into the last item. The paragraph itself + # becomes the terminal header, labelled SIGNATURES so every later + # stage treats it exactly like a real one. Only after the last Item + # header: the sentence is the signature block's and nothing else's, + # but the cover page carries a similar "Pursuant to Section 13 or + # 15(d)" line, and that must never end an item. + # AmeriServ's 2023-03-20 8-K (0001104659-23-034205) is the case; + # its test passed for months only because hard-wrapped text split + # the phrase it searched for (gh-1370 exposed it). + if not has_sig_header: + from edgar.documents.nodes import ParagraphNode + item_positions = [pos for _, text, pos in headers + if re.match(r'^\s*ITEM\s+\d', text, re.IGNORECASE)] + if item_positions: + last_item = max(item_positions) + for node in document.root.find(lambda n: isinstance(n, ParagraphNode)): + text = ' '.join((node.text() or '').split()) + if not _SIGNATURE_STATEMENT.match(text): + continue + position = _node_position(node) + if position <= last_item: + continue + headers.append((node, 'SIGNATURES', position)) + break + # Collapse the source's line wrapping, once, after every strategy has # run. See _normalize_header_text for what it costs to skip this. # diff --git a/scripts/check_offline_audit.py b/scripts/check_offline_audit.py index 998815d68..03b79b181 100644 --- a/scripts/check_offline_audit.py +++ b/scripts/check_offline_audit.py @@ -86,6 +86,20 @@ def parse_report(text: str) -> set[str]: (SUMMARY_RE.match(line) for line in text.splitlines()) if m} +def nothing_to_audit(text: str) -> bool: + """True when pytest selected no `fast` test at all, which is a clean result. + + A changed file whose tests are all `network`-marked is deselected in full by + `-m fast`: pytest prints "N deselected" and no "passed", exactly like a run + that collected nothing for a worse reason. Telling them apart by the + summary line keeps the "produced no result" guard for collection errors + while letting such a PR through. The first PR to edit only a network test + file (#1393, test_eightK.py) failed the gate here with exit 2. + """ + return (bool(re.search(r"\b\d+ deselected\b", text)) + and not re.search(r"\b\d+ (?:passed|failed|errors?)\b", text)) + + def read_baseline() -> set[str]: if not BASELINE_PATH.exists(): return set() @@ -124,6 +138,9 @@ def main() -> int: if not failing and not args.report: # A run that collected nothing also reports no failures. Tell them apart. + if nothing_to_audit(text): + print("OK: no `fast` tests in the audited paths; nothing to audit.") + return 0 if "passed" not in text: print("Audit produced no result — pytest output was:\n" + text[-2000:], file=sys.stderr) return 2 diff --git a/tests/company_reports/test_eightK.py b/tests/company_reports/test_eightK.py index 7067cf70a..0d82b2df4 100644 --- a/tests/company_reports/test_eightK.py +++ b/tests/company_reports/test_eightK.py @@ -56,9 +56,16 @@ def test_eightk_with_no_signature_header(): accession_no='0001104659-23-034205') eightk = filing.obj() assert eightk.items == ['Item 8.01', 'Item 9.01'] - assert "Cover Page" in eightk['Item 9.01'] - # We did not include the signature in the item - assert 'Pursuant to the requirements of the Securities Exchange Act' not in eightk['Item 9.01'] + item_901 = eightk['Item 9.01'] + assert "Cover Page" in item_901 + # The signature block is not part of the item. Compared on normalized + # whitespace: this assertion passed for months on raw text only because a + # hard line break split the phrase, while the block sat inside the item all + # along (exposed by gh-1370; fixed by Strategy 5c in the pattern extractor). + words = " ".join(item_901.split()) + assert 'Pursuant to the requirements of the Securities Exchange Act' not in words + assert 'Michael D. Lynch' not in words + assert words.endswith("File (embedded within the Inline XBRL document).") def test_eightk_item502_parsed_correctly(): filing = Filing(form='8-K', filing_date='2023-03-20', company='4Front Ventures Corp.', cik=1783875, diff --git a/tests/meta/test_offline_audit_ratchet.py b/tests/meta/test_offline_audit_ratchet.py index 5787b7612..ac0349aae 100644 --- a/tests/meta/test_offline_audit_ratchet.py +++ b/tests/meta/test_offline_audit_ratchet.py @@ -123,3 +123,16 @@ def test_every_baseline_entry_names_a_test_that_exists(audit): missing = sorted({node.split("::", 1)[0] for node in audit.read_baseline() if not (ROOT / node.split("::", 1)[0]).exists()}) assert missing == [], f"baseline names files that no longer exist: {missing}" + + +@pytest.mark.parametrize("summary,expected", [ + # Every test in the file is network-marked: the PR #1393 case. + ("21 deselected, 4 warnings in 0.04s", True), + ("3 passed, 18 deselected in 1.20s", False), + ("1 failed, 2 passed, 18 deselected in 1.20s", False), + # Deselection alongside a collection error is still a broken run. + ("1 error, 21 deselected in 0.30s", False), + ("no tests ran in 0.01s", False), +]) +def test_an_all_network_file_is_nothing_to_audit_not_no_result(audit, summary, expected): + assert audit.nothing_to_audit(summary) is expected