code-modernization: keep one citation per rule when rendering business rules

The extractor is told to cite a single file range per rule, but on a large
Perl codebase it kept returning two or three files and a sentence of
explanation in the source field, which the report and the pane cannot
resolve to a line. The renderer now takes the first path:line-range as the
rule's citation and puts anything after it on an "Also cited" line.
This commit is contained in:
Morgan Lunt
2026-09-24 20:29:13 -07:00
parent f33999b153
commit e662d6d338
2 changed files with 37 additions and 3 deletions
@@ -12,6 +12,7 @@ a heading or a table cell. Standard library only.
"""
import json
import os
import re
import sys
CATEGORIES = ['Calculation', 'Validation', 'Lifecycle', 'Policy']
@@ -24,6 +25,21 @@ def one_line(value, limit=300):
return text if len(text) <= limit else text[: limit - 1].rstrip() + '…'
CITATION = re.compile(r'^\s*`?([^\s`:,;()]+):(\d+)(?:\s*[-\u2013]\s*(\d+))?')
def split_citation(value):
"""One citation per rule: the first path:line[-line] in the text is it, and whatever follows
(a second file, a note) comes back separately so later tools can rely on the citation alone."""
text = ' '.join(str(value if value is not None else '').split())
m = CITATION.match(text)
if not m:
return one_line(text, 200), ''
primary = f'{m.group(1)}:{m.group(2)}' + (f'-{m.group(3)}' if m.group(3) else '')
rest = text[m.end():].strip(' \t;,.-\u2013\u2014`')
return one_line(primary, 200), one_line(rest, 300)
def block(value):
"""Multi-line text: keep the lines, but never let a value open a heading or a fence."""
lines = []
@@ -37,11 +53,14 @@ def block(value):
def render_rule(number, rule):
rid = f'RULE-{number:03d}'
source, also = split_citation(rule.get('source'))
out = [f"### {rid}: {one_line(rule.get('name'), 120)}",
f"**Category:** {one_line(rule.get('category'), 20)}",
f"**Priority:** {one_line(rule.get('priority'), 4)}",
f"**Source:** `{one_line(rule.get('source'), 200).replace(chr(39), '')}`",
f"**Plain English:** {one_line(rule.get('plainEnglish'), 400)}",
f"**Source:** `{source.replace(chr(39), '')}`"]
if also:
out.append(f'**Also cited:** {also}')
out += [f"**Plain English:** {one_line(rule.get('plainEnglish'), 400)}",
'**Specification:**',
f" Given {block(rule.get('given'))}",
f" When {block(rule.get('when'))}",
@@ -99,7 +118,7 @@ def main(argv):
'| ID | Name | Category | Priority | Source | Confidence |', '|---|---|---|---|---|---|']
for n, r in numbered:
doc.append(f"| RULE-{n:03d} | {one_line(r.get('name'), 80)} | {one_line(r.get('category'), 20)} | "
f"{one_line(r.get('priority'), 4)} | `{one_line(r.get('source'), 80).replace(chr(39), '')}` | {one_line(r.get('confidence'), 10)} |")
f"{one_line(r.get('priority'), 4)} | `{split_citation(r.get('source'))[0][:80].replace(chr(39), '')}` | {one_line(r.get('confidence'), 10)} |")
doc.append('')
for category in CATEGORIES + [None]:
group = [n for n, r in numbered if (r.get('category') if r.get('category') in CATEGORIES else None) == category]
@@ -159,6 +159,21 @@ class RenderTests(unittest.TestCase):
dto = open(os.path.join(ws, 'analysis/s/DATA_OBJECTS.md'), encoding='utf-8').read()
self.assertIn('## Account', dto)
def test_a_rule_keeps_one_citation_and_the_rest_moves_to_also_cited(self):
sources = ['wwwroot/a.pl:100-120 (aggregation); family table at lib/b.pm:31-40',
'lib/x.pm:966,1018,1238 (order lists)', 'no citation here', 'c.cbl:7']
result = {'confirmedRules': [
{'name': f'R{i}', 'category': 'Policy', 'priority': 'P1', 'source': src, 'plainEnglish': 'x',
'given': 'g', 'when': 'w', 'then': 't', 'confidence': 'High'} for i, src in enumerate(sources)]}
with tempfile.TemporaryDirectory() as ws:
write(ws, 'analysis/s/rules_result.json', json.dumps(result))
self.assertEqual(run('render_rules.py', 's', '--workspace', ws).returncode, 0)
text = open(os.path.join(ws, 'analysis/s/BUSINESS_RULES.md'), encoding='utf-8').read()
cites = re.findall(r'^\*\*Source:\*\* `([^`]*)`', text, re.M)
self.assertEqual(sorted(cites), sorted(['wwwroot/a.pl:100-120', 'lib/x.pm:966', 'no citation here', 'c.cbl:7']))
self.assertIn('**Also cited:** (aggregation); family table at lib/b.pm:31-40', text)
self.assertEqual(text.count('**Also cited:**'), 2)
def test_unreadable_result_is_an_error(self):
with tempfile.TemporaryDirectory() as ws:
self.assertEqual(run('render_rules.py', 's', '--workspace', ws).returncode, 1)