from pathlib import Path from click.testing import CliRunner from markitect_tool.cli import main from markitect_tool.contract import ( check_markdown_file, collect_metrics, load_contract_file, validate_contract, ) from markitect_tool.core import parse_markdown EXAMPLE_CASES = [ ( "adr", Path("examples/contracts/adr.contract.md"), Path("examples/documents/adr-valid.md"), Path("examples/documents/adr-invalid.md"), { "contract.field.missing", "contract.metric.too_low", "contract.assertion.contains_any_missing", "contract.section.missing", "contract.section.recommended_missing", "contract.section.forbidden", }, ), ( "prd-frs", Path("examples/contracts/prd-frs.contract.md"), Path("examples/documents/prd-frs-valid.md"), Path("examples/documents/prd-frs-invalid.md"), { "contract.field.missing", "contract.metric.too_low", "contract.assertion.contains_any_missing", "contract.section.missing", "contract.section.recommended_missing", "contract.section.discouraged", }, ), ( "workplan", Path("examples/contracts/workplan.contract.md"), Path("examples/documents/workplan-valid.md"), Path("examples/documents/workplan-invalid.md"), { "contract.field.missing", "contract.field.enum", "contract.assertion.contains_missing", "contract.section.recommended_missing", }, ), ( "business-letter", Path("examples/contracts/business-letter.contract.md"), Path("examples/documents/business-letter-valid.md"), Path("examples/documents/business-letter-invalid.md"), { "contract.field.missing", "contract.section.missing", "contract.metric.too_low", }, ), ( "concept-note", Path("examples/contracts/concept-note.contract.md"), Path("examples/documents/concept-note-valid.md"), Path("examples/documents/concept-note-invalid.md"), { "contract.field.enum", "contract.metric.too_low", "contract.section.missing", }, ), ] CONTRACT_TEXT = """--- title: ADR Contract version: "1.0" --- # ADR Contract ```yaml contract id: adr-contract-v1 document: type: adr title: Architecture Decision Record fields: status: type: string required: true enum: [proposed, accepted, superseded] metrics: document: words: min: 12 max: 240 severity: warning sections: - id: context title: Context presence: required level: 2 order: before: decision metrics: words: min: 4 max: 80 severity: warning assertions: - id: context-names-problem contains_any: [problem, motivation] severity: warning guidance: Explain why the decision exists. - id: decision title: Decision presence: required level: 2 assertions: - id: decision-commits matches: "\\\\b(choose|adopt|use|will)\\\\b" severity: error guidance: State the actual decision, not only background. - id: consequences title: Consequences presence: recommended level: 2 - id: deprecated title: Deprecated Approach presence: forbidden ``` """ VALID_ADR = """--- document_type: adr status: accepted --- # Use Markdown Contracts ## Context The problem is that plain heading counts do not explain whether content is useful. ## Decision We will use a markdown-native document contract with deterministic diagnostics. ## Consequences The tool can check author intent before generation or review work continues. """ INVALID_ADR = """--- document_type: adr --- # Weak ADR ## Context This is short. ## Deprecated Approach This section should not be here. """ def test_load_contract_file_extracts_markdown_yaml_contract(tmp_path: Path): contract_file = tmp_path / "adr.contract.md" contract_file.write_text(CONTRACT_TEXT, encoding="utf-8") contract = load_contract_file(contract_file) assert contract.id == "adr-contract-v1" assert contract.document_type == "adr" assert contract.fields[0].id == "status" assert [section.id for section in contract.sections] == [ "context", "decision", "consequences", "deprecated", ] def test_validate_contract_accepts_complete_contract(tmp_path: Path): contract_file = tmp_path / "adr.contract.md" contract_file.write_text(CONTRACT_TEXT, encoding="utf-8") result = validate_contract(load_contract_file(contract_file)) assert result.valid is True assert result.diagnostics == [] def test_validate_contract_reports_bad_regex(tmp_path: Path): contract_file = tmp_path / "bad.contract.md" contract_file.write_text( CONTRACT_TEXT.replace("\\\\b(choose|adopt|use|will)\\\\b", "[bad"), encoding="utf-8", ) result = validate_contract(load_contract_file(contract_file)) assert result.valid is False assert result.diagnostics[0].code == "contract.regex.invalid" def test_check_markdown_file_accepts_valid_document(tmp_path: Path): contract_file = tmp_path / "adr.contract.md" document_file = tmp_path / "adr.md" contract_file.write_text(CONTRACT_TEXT, encoding="utf-8") document_file.write_text(VALID_ADR, encoding="utf-8") result = check_markdown_file(document_file, contract_file) assert result.valid is True assert result.diagnostics == [] assert result.metrics["document"]["sections"] == 4 def test_check_markdown_file_reports_practical_failures(tmp_path: Path): contract_file = tmp_path / "adr.contract.md" document_file = tmp_path / "adr.md" contract_file.write_text(CONTRACT_TEXT, encoding="utf-8") document_file.write_text(INVALID_ADR, encoding="utf-8") result = check_markdown_file(document_file, contract_file) codes = {diagnostic.code for diagnostic in result.diagnostics} assert result.valid is False assert "contract.field.missing" in codes assert "contract.section.missing" in codes assert "contract.section.forbidden" in codes assert "contract.metric.too_low" in codes def test_check_markdown_file_keeps_warning_only_results_valid(tmp_path: Path): contract_file = tmp_path / "adr.contract.md" document_file = tmp_path / "adr.md" contract_file.write_text(CONTRACT_TEXT, encoding="utf-8") document_file.write_text( VALID_ADR.replace("The problem is", "The situation is"), encoding="utf-8", ) result = check_markdown_file(document_file, contract_file) assert result.valid is True assert [diagnostic.code for diagnostic in result.diagnostics] == [ "contract.assertion.contains_any_missing" ] assert result.diagnostics[0].severity == "warning" def test_collect_metrics_counts_document_and_sections(): document = parse_markdown(VALID_ADR) metrics = collect_metrics(document) assert metrics.words > 20 assert metrics.sections == 4 context_metrics = next( section for section in metrics.section_metrics if section.heading == "Context" ) assert context_metrics.words >= 10 def test_mkt_contract_validate(tmp_path: Path): contract_file = tmp_path / "adr.contract.md" contract_file.write_text(CONTRACT_TEXT, encoding="utf-8") result = CliRunner().invoke(main, ["contract", "validate", str(contract_file)]) assert result.exit_code == 0 assert "valid" in result.output def test_mkt_contract_check_reports_invalid_document(tmp_path: Path): contract_file = tmp_path / "adr.contract.md" document_file = tmp_path / "adr.md" contract_file.write_text(CONTRACT_TEXT, encoding="utf-8") document_file.write_text(INVALID_ADR, encoding="utf-8") result = CliRunner().invoke( main, ["contract", "check", str(document_file), "--contract", str(contract_file)] ) assert result.exit_code == 1 assert "contract.section.missing" in result.output assert "guidance" in result.output def test_mkt_metrics_outputs_text(tmp_path: Path): document_file = tmp_path / "adr.md" document_file.write_text(VALID_ADR, encoding="utf-8") result = CliRunner().invoke(main, ["metrics", str(document_file)]) assert result.exit_code == 0 assert "document" in result.output assert "words" in result.output assert "Context" in result.output def test_example_contracts_validate(): for _name, contract_path, _valid_path, _invalid_path, _expected in EXAMPLE_CASES: result = validate_contract(load_contract_file(contract_path)) assert result.valid is True def test_example_valid_documents_have_no_error_diagnostics(): for name, contract_path, valid_path, _invalid_path, _expected in EXAMPLE_CASES: result = check_markdown_file(valid_path, contract_path) assert result.valid is True, name assert all(diagnostic.severity != "error" for diagnostic in result.diagnostics) def test_example_invalid_documents_report_expected_diagnostics(): for name, contract_path, _valid_path, invalid_path, expected in EXAMPLE_CASES: result = check_markdown_file(invalid_path, contract_path) codes = {diagnostic.code for diagnostic in result.diagnostics} assert result.valid is False, name assert expected <= codes