Joshua Sundance Bailey
loosecanvas: local AI thought-mapping canvas with a trust-tagged knowledge graph
6d1438c | """M07 — Config + Docs Extractor. Red/green tests derived from the spec. | |
| Success criteria under test (archive/plan-poc-m01-m23/modules/02-layer-1-extraction-pipeline/m07): | |
| - Parses pyproject/requirements/GitHub Actions/README into ``Node``/``Edge`` lists. | |
| - ``pyproject.toml`` yields a project node plus >=1 dependency node + ``depends_on`` edge. | |
| - ``requirements.txt`` yields one node per real requirement; comments/blank lines skipped. | |
| - ``README.md`` headings yield a document node plus section nodes + ``contains`` edges. | |
| - A GitHub Actions workflow yields a workflow node plus job nodes (parsed via safe_load). | |
| - Malformed TOML / YAML and a missing file degrade gracefully (no raise; empty/partial). | |
| """ | |
| from __future__ import annotations | |
| from pathlib import Path | |
| from loosecanvas.contracts import Edge, Node | |
| from loosecanvas.extractors.config_extractor import ( | |
| extract_github_actions, | |
| extract_pyproject, | |
| extract_readme, | |
| extract_requirements, | |
| ) | |
| def test_extract_pyproject_yields_project_and_dependency_nodes(tmp_path: Path) -> None: | |
| path = tmp_path / "pyproject.toml" | |
| path.write_text( | |
| """ | |
| [project] | |
| name = "demo-pkg" | |
| version = "1.2.3" | |
| dependencies = [ | |
| "requests>=2.0", | |
| "pydantic>=2", | |
| ] | |
| """, | |
| encoding="utf-8", | |
| ) | |
| nodes, edges = extract_pyproject(path) | |
| assert all(isinstance(n, Node) for n in nodes) | |
| assert all(isinstance(e, Edge) for e in edges) | |
| project_nodes = [n for n in nodes if n.kind == "package"] | |
| assert len(project_nodes) == 1 | |
| assert project_nodes[0].label == "demo-pkg" | |
| dep_labels = {n.label for n in nodes if n.kind == "dependency"} | |
| assert "requests>=2.0" in dep_labels | |
| assert "pydantic>=2" in dep_labels | |
| assert len(edges) >= 2 | |
| assert all(e.kind == "depends_on" for e in edges) | |
| assert all(e.source == project_nodes[0].id for e in edges) | |
| def test_extract_requirements_skips_comments_and_blanks(tmp_path: Path) -> None: | |
| path = tmp_path / "requirements.txt" | |
| path.write_text( | |
| """ | |
| # top-level comment | |
| requests>=2.0 | |
| pydantic==2.5.0 # inline comment | |
| numpy | |
| """, | |
| encoding="utf-8", | |
| ) | |
| nodes, edges = extract_requirements(path) | |
| labels = [n.label for n in nodes] | |
| kinds = {n.kind for n in nodes} | |
| assert kinds == {"dependency"} | |
| assert len(nodes) == 3 | |
| assert "requests>=2.0" in labels | |
| assert "pydantic==2.5.0" in labels | |
| assert "numpy" in labels | |
| assert edges == [] | |
| def test_extract_readme_yields_document_and_section_nodes(tmp_path: Path) -> None: | |
| path = tmp_path / "README.md" | |
| path.write_text( | |
| """# Project Title | |
| Intro paragraph. | |
| ## Installation | |
| Steps here. | |
| ## Usage | |
| More text. | |
| """, | |
| encoding="utf-8", | |
| ) | |
| nodes, edges = extract_readme(path) | |
| doc_nodes = [n for n in nodes if n.kind == "document"] | |
| assert len(doc_nodes) == 1 | |
| section_labels = [n.label for n in nodes if n.kind == "section"] | |
| assert "Project Title" in section_labels | |
| assert "Installation" in section_labels | |
| assert "Usage" in section_labels | |
| assert edges | |
| assert all(e.kind == "contains" for e in edges) | |
| assert all(e.source == doc_nodes[0].id for e in edges) | |
| def test_extract_github_actions_yields_workflow_and_job_nodes(tmp_path: Path) -> None: | |
| path = tmp_path / "ci.yml" | |
| path.write_text( | |
| """ | |
| name: CI | |
| on: [push] | |
| jobs: | |
| build: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - run: echo build | |
| test: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - run: echo test | |
| """, | |
| encoding="utf-8", | |
| ) | |
| nodes, edges = extract_github_actions(path) | |
| workflow_nodes = [n for n in nodes if n.kind == "workflow"] | |
| assert len(workflow_nodes) == 1 | |
| assert workflow_nodes[0].label == "CI" | |
| job_labels = {n.label for n in nodes if n.kind == "job"} | |
| assert job_labels == {"build", "test"} | |
| assert len(edges) == 2 | |
| assert all(e.kind == "contains" for e in edges) | |
| assert all(e.source == workflow_nodes[0].id for e in edges) | |
| def test_malformed_toml_degrades_gracefully(tmp_path: Path) -> None: | |
| path = tmp_path / "pyproject.toml" | |
| path.write_text("this is = = not valid toml [[[", encoding="utf-8") | |
| nodes, edges = extract_pyproject(path) | |
| assert nodes == [] | |
| assert edges == [] | |
| def test_malformed_yaml_degrades_gracefully(tmp_path: Path) -> None: | |
| path = tmp_path / "ci.yml" | |
| path.write_text("name: CI\n bad: : : indentation\n\t- nope", encoding="utf-8") | |
| nodes, edges = extract_github_actions(path) | |
| assert nodes == [] | |
| assert edges == [] | |
| def test_missing_file_degrades_gracefully(tmp_path: Path) -> None: | |
| missing = tmp_path / "does_not_exist.toml" | |
| assert extract_pyproject(missing) == ([], []) | |
| assert extract_requirements(tmp_path / "missing.txt") == ([], []) | |
| assert extract_readme(tmp_path / "missing.md") == ([], []) | |
| assert extract_github_actions(tmp_path / "missing.yml") == ([], []) | |