diff --git a/linear/docs/process/linear_type_classifier_corrections_v1.md b/linear/docs/process/linear_type_classifier_corrections_v1.md index 26ebbeb..d65f93c 100644 --- a/linear/docs/process/linear_type_classifier_corrections_v1.md +++ b/linear/docs/process/linear_type_classifier_corrections_v1.md @@ -19,6 +19,9 @@ When automation hygiene mutates Linear issue type/routing and CJ corrects it: 3. Update `linear/contracts/linear_type_classifier_v1.json` or add a classifier test fixture in the same run/PR. 4. Reference the changed rule/test in the audit report back to CJ. +Helper: +- `python3 linear/scripts/record_type_classifier_correction.py` can append an entry here and (optionally) add a machine-enforced fixture row in `linear/tests/fixtures/linear_type_classifier_corrections_v1.json` (BIT-441). + ## Entry format ```md diff --git a/linear/scripts/record_type_classifier_correction.py b/linear/scripts/record_type_classifier_correction.py new file mode 100644 index 0000000..43b1fc9 --- /dev/null +++ b/linear/scripts/record_type_classifier_correction.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +""" +Record a CJ correction to the Linear issue-type classifier in a durable way. + +BIT-441 intent: +- keep the human-readable correction log in one place +- ensure every correction also becomes machine-enforced via a test fixture + +This script is intentionally simple and local-only (repo mutation). +""" + +from __future__ import annotations + +import argparse +import json +from datetime import date +from pathlib import Path +from typing import Any, Dict, List + + +ROOT = Path(__file__).resolve().parents[1] +CORRECTIONS_MD = ROOT / "docs" / "process" / "linear_type_classifier_corrections_v1.md" +FIXTURE_JSON = ROOT / "tests" / "fixtures" / "linear_type_classifier_corrections_v1.json" + + +def today_iso() -> str: + return date.today().isoformat() + + +def append_md_entry( + *, + when: str, + title: str, + source: str, + automation_chose: str, + cj_correction: str, + rule_learned: str, + machine_update: str, +) -> None: + entry = ( + f"\n### {when} — {title}\n\n" + f"- Source: {source}\n" + f"- Automation chose: {automation_chose}\n" + f"- CJ correction: {cj_correction}\n" + f"- Rule learned: {rule_learned}\n" + f"- Machine update: {machine_update}\n" + ) + + text = CORRECTIONS_MD.read_text(encoding="utf-8") + marker = "_No corrections recorded yet._" + if marker in text: + text = text.replace(marker, "").rstrip() + "\n" + CORRECTIONS_MD.write_text(text.rstrip() + "\n" + entry.lstrip(), encoding="utf-8") + + +def load_fixture() -> List[Dict[str, Any]]: + if not FIXTURE_JSON.exists(): + return [] + payload = json.loads(FIXTURE_JSON.read_text(encoding="utf-8") or "[]") + if not isinstance(payload, list): + raise SystemExit(f"invalid fixture JSON (expected list): {FIXTURE_JSON}") + return payload + + +def write_fixture(rows: List[Dict[str, Any]]) -> None: + FIXTURE_JSON.parent.mkdir(parents=True, exist_ok=True) + FIXTURE_JSON.write_text(json.dumps(rows, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def main() -> int: + ap = argparse.ArgumentParser(description="Record a Linear type-classifier correction (BIT-441).") + ap.add_argument("--date", default=today_iso(), help="Entry date (YYYY-MM-DD). Defaults to today.") + ap.add_argument("--title", required=True, help="Short correction title.") + ap.add_argument("--source", required=True, help="BIT-000 or hygiene audit run link.") + ap.add_argument("--automation-chose", required=True, help="What automation chose (type/route).") + ap.add_argument("--cj-correction", required=True, help="CJ correction (type/route).") + ap.add_argument("--rule-learned", required=True, help="One-sentence learned rule.") + ap.add_argument( + "--machine-update", + default="pending", + help="Classifier rule/test path, or 'pending' (not recommended).", + ) + + ap.add_argument( + "--intake-json", + default="", + help="Optional JSON object containing the classifier intake fields to enforce via test fixture.", + ) + ap.add_argument( + "--expected-type", + default="", + help="Optional expected canonical type label to enforce for --intake-json (e.g., '⚙️ Chore').", + ) + + args = ap.parse_args() + + if not CORRECTIONS_MD.exists(): + raise SystemExit(f"missing corrections log: {CORRECTIONS_MD}") + + append_md_entry( + when=args.date, + title=args.title.strip(), + source=args.source.strip(), + automation_chose=args.automation_chose.strip(), + cj_correction=args.cj_correction.strip(), + rule_learned=args.rule_learned.strip(), + machine_update=args.machine_update.strip(), + ) + + if args.intake_json.strip(): + intake: Dict[str, Any] = json.loads(args.intake_json) + if not isinstance(intake, dict): + raise SystemExit("--intake-json must be a JSON object") + if not args.expected_type.strip(): + raise SystemExit("--expected-type is required when --intake-json is set") + rows = load_fixture() + rows.append( + { + "date": args.date, + "title": args.title.strip(), + "source": args.source.strip(), + "intake": intake, + "expected_type": args.expected_type.strip(), + } + ) + write_fixture(rows) + + print(f"updated: {CORRECTIONS_MD}") + if args.intake_json.strip(): + print(f"updated: {FIXTURE_JSON}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) + diff --git a/linear/tests/fixtures/linear_type_classifier_corrections_v1.json b/linear/tests/fixtures/linear_type_classifier_corrections_v1.json new file mode 100644 index 0000000..7dd4387 --- /dev/null +++ b/linear/tests/fixtures/linear_type_classifier_corrections_v1.json @@ -0,0 +1,2 @@ +[] + diff --git a/linear/tests/test_classifier_corrections.py b/linear/tests/test_classifier_corrections.py new file mode 100644 index 0000000..54aa8fc --- /dev/null +++ b/linear/tests/test_classifier_corrections.py @@ -0,0 +1,33 @@ +import json +import unittest +from pathlib import Path + +from linear.src.engine import LinearBotEngine + + +FIXTURE_PATH = Path(__file__).resolve().parent / "fixtures" / "linear_type_classifier_corrections_v1.json" + + +class TestClassifierCorrections(unittest.TestCase): + def setUp(self) -> None: + self.bot = LinearBotEngine() + + def test_corrections_fixture_enforces_expected_types(self): + if not FIXTURE_PATH.exists(): + self.fail(f"missing fixture: {FIXTURE_PATH}") + + rows = json.loads(FIXTURE_PATH.read_text(encoding="utf-8") or "[]") + self.assertIsInstance(rows, list) + + for idx, row in enumerate(rows, start=1): + intake = row.get("intake") + expected = row.get("expected_type") + self.assertIsInstance(intake, dict, f"row {idx}: intake must be an object") + self.assertIsInstance(expected, str, f"row {idx}: expected_type must be a string") + predicted, reason = self.bot.classify_issue_type(intake) + self.assertEqual( + expected, + predicted, + f"row {idx}: expected {expected!r} but got {predicted!r}. reason={reason}", + ) +