Skip to content

Commit 14e03cf

Browse files
committed
Enforce operator-friction closeout links
Add a docs-consistency lint for operator-friction entries. The lint ignores markdown templates, parses only official evidence/hypothesis sections, rejects malformed headings and source/type mismatches, and requires closed entries to carry both closure evidence and promoted artifacts. Constraint: Markdown structure can prove traceability shape, not true owner authorship. Rejected: Owner evidence count quotas | they would incentivize invented entries rather than real use. Confidence: high Scope-risk: narrow Directive: Do not treat Closure evidence or Promoted to as proof of owner experience; they are trace links only. Tested: .venv/bin/python -m pytest tests/test_docs_consistency.py -q; .venv/bin/python -m pytest tests/integration/test_audit_sink_isolation.py tests/acceptance/test_hook_lifecycle_flow.py tests/test_event_spine_wiring.py tests/lifecycle/test_run_event_spine.py tests/test_docs_consistency.py -q; .venv/bin/python scripts/validate_docs_consistency.py; .venv/bin/ruff check scripts/validate_docs_consistency.py tests/test_docs_consistency.py; .venv/bin/ruff format --check scripts/validate_docs_consistency.py tests/test_docs_consistency.py; .venv/bin/mypy scripts/validate_docs_consistency.py tests/test_docs_consistency.py; .venv/bin/python scripts/validate_event_spine_wiring.py; .venv/bin/python scripts/detect_stale_plans.py; git diff --check Not-tested: Full repository test suite.
1 parent 43a29bc commit 14e03cf

2 files changed

Lines changed: 247 additions & 0 deletions

File tree

scripts/validate_docs_consistency.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -954,6 +954,11 @@ def _load_generate_command_snippet_inventory_module() -> ModuleType:
954954
'in progress',
955955
}
956956
)
957+
FRICTION_LOG_EMPTY_LINK_VALUES = frozenset({'', 'n/a', 'na', '-', '—'})
958+
FRICTION_LOG_ENTRY_SECTIONS = frozenset(
959+
{'Owner Evidence Entries', 'Competitor-Derived Hypotheses'}
960+
)
961+
FRICTION_LOG_ENTRY_HEADING = re.compile(r'^### \d{4}-\d{2}-\d{2} - .+')
957962
_CJK_CHAR = re.compile(r'[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]')
958963

959964

@@ -996,6 +1001,127 @@ def validate_work_log_canonical_states(
9961001
return errors
9971002

9981003

1004+
def _normalize_friction_field(value: str) -> str:
1005+
return re.sub(r'[*_`]', '', value).strip().lower()
1006+
1007+
1008+
def _is_empty_friction_link(value: str | None) -> bool:
1009+
if value is None:
1010+
return True
1011+
return _normalize_friction_field(value) in FRICTION_LOG_EMPTY_LINK_VALUES
1012+
1013+
1014+
def _extract_operator_friction_entries(
1015+
friction_log_text: str,
1016+
) -> tuple[list[tuple[int, str, dict[str, str]]], list[str]]:
1017+
entries: list[tuple[int, str, dict[str, str]]] = []
1018+
errors: list[str] = []
1019+
in_fence = False
1020+
active_section: str | None = None
1021+
current_line: int | None = None
1022+
current_title: str | None = None
1023+
current_fields: dict[str, str] = {}
1024+
1025+
for line_no, line in enumerate(friction_log_text.splitlines(), start=1):
1026+
if line.strip().startswith('```'):
1027+
in_fence = not in_fence
1028+
continue
1029+
if in_fence:
1030+
continue
1031+
1032+
if line.startswith('## '):
1033+
if current_line is not None and current_title is not None:
1034+
entries.append((current_line, current_title, current_fields))
1035+
current_line = None
1036+
current_title = None
1037+
current_fields = {}
1038+
active_section = line.removeprefix('## ').strip()
1039+
continue
1040+
1041+
if line.startswith('### '):
1042+
if active_section not in FRICTION_LOG_ENTRY_SECTIONS:
1043+
continue
1044+
if current_line is not None and current_title is not None:
1045+
entries.append((current_line, current_title, current_fields))
1046+
if not FRICTION_LOG_ENTRY_HEADING.match(line):
1047+
errors.append(
1048+
'operator-friction-log.md entry heading at line '
1049+
f'{line_no} must use "### YYYY-MM-DD - Short title".'
1050+
)
1051+
current_line = line_no
1052+
current_title = line.removeprefix('### ').strip()
1053+
current_fields = {}
1054+
continue
1055+
1056+
if current_line is None:
1057+
continue
1058+
1059+
match = re.match(r'- \*\*(?P<field>[^*]+):\*\*\s*(?P<value>.*)$', line)
1060+
if match:
1061+
current_fields[match.group('field').strip()] = match.group('value').strip()
1062+
1063+
if current_line is not None and current_title is not None:
1064+
entries.append((current_line, current_title, current_fields))
1065+
1066+
return entries, errors
1067+
1068+
1069+
def validate_operator_friction_log(friction_log_text: str) -> list[str]:
1070+
"""Ensure owner-friction entries cannot close without promotion evidence."""
1071+
entries, errors = _extract_operator_friction_entries(friction_log_text)
1072+
for line_no, title, fields in entries:
1073+
entry_type = _normalize_friction_field(fields.get('Type', ''))
1074+
if entry_type not in {'evidence', 'hypothesis'}:
1075+
errors.append(
1076+
f'operator-friction-log.md entry {title!r} at line {line_no} '
1077+
"has invalid Type; expected 'evidence' or 'hypothesis'."
1078+
)
1079+
1080+
source = _normalize_friction_field(fields.get('Source', ''))
1081+
if entry_type == 'evidence' and source != 'owner real use':
1082+
errors.append(
1083+
f'operator-friction-log.md evidence entry {title!r} at line '
1084+
f'{line_no} must use Source: owner real use; structured fields '
1085+
'do not independently prove owner authorship.'
1086+
)
1087+
if entry_type == 'hypothesis' and '[hypothesis:' not in source:
1088+
errors.append(
1089+
f'operator-friction-log.md hypothesis entry {title!r} at line '
1090+
f'{line_no} must keep a [hypothesis: source, date] Source.'
1091+
)
1092+
1093+
status = _normalize_friction_field(fields.get('Status', ''))
1094+
if status not in {'open', 'closed', 'rejected'}:
1095+
errors.append(
1096+
f'operator-friction-log.md entry {title!r} at line {line_no} '
1097+
"has invalid Status; expected 'open', 'closed', or 'rejected'."
1098+
)
1099+
continue
1100+
1101+
if entry_type == 'hypothesis' and status == 'closed':
1102+
errors.append(
1103+
f'operator-friction-log.md hypothesis entry {title!r} at line '
1104+
f'{line_no} cannot be marked closed; reject it or promote it '
1105+
'after owner validation.'
1106+
)
1107+
1108+
if status != 'closed':
1109+
continue
1110+
1111+
if _is_empty_friction_link(fields.get('Closure evidence')):
1112+
errors.append(
1113+
f'operator-friction-log.md closed entry {title!r} at line {line_no} '
1114+
'must cite non-n/a Closure evidence.'
1115+
)
1116+
if _is_empty_friction_link(fields.get('Promoted to')):
1117+
errors.append(
1118+
f'operator-friction-log.md closed entry {title!r} at line {line_no} '
1119+
'must cite the promoted ticket or acceptance-gap artifact.'
1120+
)
1121+
1122+
return errors
1123+
1124+
9991125
def validate_index_status_vocabulary(index_text: str) -> list[str]:
10001126
errors: list[str] = []
10011127
if 'document-state-model.md' not in index_text:
@@ -1511,6 +1637,17 @@ def validate_docs_consistency(
15111637
path_label=str(work_log_path.relative_to(_REPO_ROOT)),
15121638
)
15131639
)
1640+
friction_log_path = (
1641+
_REPO_ROOT / 'docs' / 'work-log' / 'operator-friction-log.md'
1642+
)
1643+
if friction_log_path.is_file():
1644+
errors.extend(
1645+
validate_operator_friction_log(
1646+
friction_log_path.read_text(encoding='utf-8')
1647+
)
1648+
)
1649+
else:
1650+
errors.append(f'Operator friction log not found: {friction_log_path}')
15141651

15151652
index_path = _REPO_ROOT / 'docs' / 'INDEX.md'
15161653
if index_path.is_file():

tests/test_docs_consistency.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,116 @@ def test_validate_roadmap_status_passes_for_repo_roadmap() -> None:
154154
assert errors == []
155155

156156

157+
def test_validate_operator_friction_log_passes_for_repo_log() -> None:
158+
root = Path(__file__).resolve().parents[1]
159+
log = (root / 'docs' / 'work-log' / 'operator-friction-log.md').read_text(
160+
encoding='utf-8'
161+
)
162+
errors = _VALIDATE_MODULE.validate_operator_friction_log(log)
163+
assert errors == []
164+
165+
166+
def test_validate_operator_friction_log_detects_closed_without_links() -> None:
167+
log = (
168+
'# Operator Friction Log\n\n'
169+
'## Owner Evidence Entries\n\n'
170+
'### 2026-06-14 - Missing closeout\n\n'
171+
'- **Type:** evidence\n'
172+
'- **Source:** owner real use\n'
173+
'- **Status:** closed\n'
174+
'- **Closure evidence:** n/a\n'
175+
'- **Promoted to:** n/a\n'
176+
)
177+
errors = _VALIDATE_MODULE.validate_operator_friction_log(log)
178+
assert any('Closure evidence' in err for err in errors)
179+
assert any('promoted ticket or acceptance-gap artifact' in err for err in errors)
180+
181+
182+
def test_validate_operator_friction_log_rejects_closed_hypothesis() -> None:
183+
log = (
184+
'# Operator Friction Log\n\n'
185+
'## Competitor-Derived Hypotheses\n\n'
186+
'### 2026-06-14 - Competitor clue\n\n'
187+
'- **Type:** hypothesis\n'
188+
'- **Source:** [hypothesis: example, 2026-06-14]\n'
189+
'- **Status:** closed\n'
190+
'- **Closure evidence:** tests/test_example.py\n'
191+
'- **Promoted to:** docs/work-log/example-ticket.md\n'
192+
)
193+
errors = _VALIDATE_MODULE.validate_operator_friction_log(log)
194+
assert any(
195+
'hypothesis entry' in err and 'cannot be marked closed' in err for err in errors
196+
)
197+
198+
199+
def test_validate_operator_friction_log_allows_open_unpromoted_entry() -> None:
200+
log = (
201+
'# Operator Friction Log\n\n'
202+
'## Owner Evidence Entries\n\n'
203+
'### 2026-06-14 - Still investigating\n\n'
204+
'- **Type:** evidence\n'
205+
'- **Source:** owner real use\n'
206+
'- **Status:** open\n'
207+
'- **Closure evidence:** n/a\n'
208+
'- **Promoted to:** n/a\n'
209+
)
210+
errors = _VALIDATE_MODULE.validate_operator_friction_log(log)
211+
assert errors == []
212+
213+
214+
def test_validate_operator_friction_log_detects_malformed_heading() -> None:
215+
log = (
216+
'# Operator Friction Log\n\n'
217+
'## Owner Evidence Entries\n\n'
218+
'### June 14, 2026 - Missing ISO date\n\n'
219+
'- **Type:** evidence\n'
220+
'- **Source:** owner real use\n'
221+
'- **Status:** open\n'
222+
)
223+
errors = _VALIDATE_MODULE.validate_operator_friction_log(log)
224+
assert any('YYYY-MM-DD' in err for err in errors)
225+
226+
227+
def test_validate_operator_friction_log_detects_missing_type_field() -> None:
228+
log = (
229+
'# Operator Friction Log\n\n'
230+
'## Owner Evidence Entries\n\n'
231+
'### 2026-06-14 - Bad bullet syntax\n\n'
232+
'- Type: evidence\n'
233+
'- **Source:** owner real use\n'
234+
'- **Status:** open\n'
235+
)
236+
errors = _VALIDATE_MODULE.validate_operator_friction_log(log)
237+
assert any('invalid Type' in err for err in errors)
238+
239+
240+
def test_validate_operator_friction_log_checks_type_source_consistency() -> None:
241+
log = (
242+
'# Operator Friction Log\n\n'
243+
'## Competitor-Derived Hypotheses\n\n'
244+
'### 2026-06-14 - Bad source\n\n'
245+
'- **Type:** hypothesis\n'
246+
'- **Source:** owner real use\n'
247+
'- **Status:** open\n'
248+
)
249+
errors = _VALIDATE_MODULE.validate_operator_friction_log(log)
250+
assert any('[hypothesis: source, date]' in err for err in errors)
251+
252+
253+
def test_validate_operator_friction_log_ignores_template_code_fence() -> None:
254+
log = (
255+
'# Operator Friction Log\n\n'
256+
'```markdown\n'
257+
'### YYYY-MM-DD - Short title\n'
258+
'- **Status:** closed\n'
259+
'- **Closure evidence:** n/a\n'
260+
'- **Promoted to:** n/a\n'
261+
'```\n'
262+
)
263+
errors = _VALIDATE_MODULE.validate_operator_friction_log(log)
264+
assert errors == []
265+
266+
157267
def test_current_roadmap_stays_owner_operator_harness_first() -> None:
158268
root = Path(__file__).resolve().parents[1]
159269
readme = (root / 'README.md').read_text(encoding='utf-8')

0 commit comments

Comments
 (0)