From 98322ced17518a190223c4581ab065adf7871124 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Nov 2026 10:55:20 +0000 Subject: [PATCH 01/44] Initial plan From 2cfebd9f8f50550baa3240ceb7a5fe4edb8c8867 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Nov 2026 10:58:14 +0000 Subject: [PATCH 02/44] Add Streamlit app foundation and initial pages Co-authored-by: CodingBunny06 <210141639+CodingBunny06@users.noreply.github.com> --- app.py | 27 ++++++++++++++++++++ pages/__init__.py | 1 + pages/attenuation_laboratory.py | 11 +++++++++ pages/fiber_explorer.py | 11 +++++++++ pages/signal_propagation.py | 11 +++++++++ tests/test_app_structure.py | 44 +++++++++++++++++++++++++++++++++ 6 files changed, 105 insertions(+) create mode 100644 app.py create mode 100644 pages/__init__.py create mode 100644 pages/attenuation_laboratory.py create mode 100644 pages/fiber_explorer.py create mode 100644 pages/signal_propagation.py create mode 100644 tests/test_app_structure.py diff --git a/app.py b/app.py new file mode 100644 index 0000000..316e005 --- /dev/null +++ b/app.py @@ -0,0 +1,27 @@ +"""FiberLab Streamlit application entry point.""" + +from __future__ import annotations + +import streamlit as st + +from pages.attenuation_laboratory import render as render_attenuation_laboratory +from pages.fiber_explorer import render as render_fiber_explorer +from pages.signal_propagation import render as render_signal_propagation + +PAGE_RENDERERS = { + "Fiber Explorer": render_fiber_explorer, + "Attenuation Laboratory": render_attenuation_laboratory, + "Signal Propagation": render_signal_propagation, +} + + +def main() -> None: + """Configure and render the Streamlit application.""" + st.set_page_config(page_title="FiberLab", page_icon="πŸ”¬", layout="wide") + + selected_page = st.sidebar.radio("Navigation", tuple(PAGE_RENDERERS.keys())) + PAGE_RENDERERS[selected_page]() + + +if __name__ == "__main__": + main() diff --git a/pages/__init__.py b/pages/__init__.py new file mode 100644 index 0000000..9f734c8 --- /dev/null +++ b/pages/__init__.py @@ -0,0 +1 @@ +"""FiberLab page modules.""" diff --git a/pages/attenuation_laboratory.py b/pages/attenuation_laboratory.py new file mode 100644 index 0000000..4e83eb1 --- /dev/null +++ b/pages/attenuation_laboratory.py @@ -0,0 +1,11 @@ +"""Attenuation Laboratory page.""" + +import streamlit as st + + +def render() -> None: + """Render the Attenuation Laboratory page.""" + st.title("Attenuation Laboratory") + st.write( + "Use this section to experiment with loss models and compare attenuation profiles." + ) diff --git a/pages/fiber_explorer.py b/pages/fiber_explorer.py new file mode 100644 index 0000000..319bd99 --- /dev/null +++ b/pages/fiber_explorer.py @@ -0,0 +1,11 @@ +"""Fiber Explorer page.""" + +import streamlit as st + + +def render() -> None: + """Render the Fiber Explorer page.""" + st.title("Fiber Explorer") + st.write( + "Use this section to investigate fiber geometry, refractive indices, and guiding conditions." + ) diff --git a/pages/signal_propagation.py b/pages/signal_propagation.py new file mode 100644 index 0000000..b834e98 --- /dev/null +++ b/pages/signal_propagation.py @@ -0,0 +1,11 @@ +"""Signal Propagation page.""" + +import streamlit as st + + +def render() -> None: + """Render the Signal Propagation page.""" + st.title("Signal Propagation") + st.write( + "Use this section to explore signal behavior, dispersion, and transmission distance." + ) diff --git a/tests/test_app_structure.py b/tests/test_app_structure.py new file mode 100644 index 0000000..8f4ba4b --- /dev/null +++ b/tests/test_app_structure.py @@ -0,0 +1,44 @@ +import importlib + +import app + + +def test_page_modules_import_cleanly(): + for module_name in ( + "pages.fiber_explorer", + "pages.attenuation_laboratory", + "pages.signal_propagation", + ): + module = importlib.import_module(module_name) + assert hasattr(module, "render") + + +def test_navigation_registry_contains_all_pages(): + assert set(app.PAGE_RENDERERS.keys()) == { + "Fiber Explorer", + "Attenuation Laboratory", + "Signal Propagation", + } + + +def test_main_renders_selected_page(monkeypatch): + calls = {} + + monkeypatch.setattr(app.st, "set_page_config", lambda **kwargs: calls.setdefault("config", kwargs)) + + class Sidebar: + @staticmethod + def radio(*_args, **_kwargs): + return "Signal Propagation" + + monkeypatch.setattr(app.st, "sidebar", Sidebar()) + monkeypatch.setattr( + app, + "PAGE_RENDERERS", + {"Signal Propagation": lambda: calls.setdefault("rendered", True)}, + ) + + app.main() + + assert calls["config"]["page_title"] == "FiberLab" + assert calls["rendered"] is True From 98b539b25990307b6c948430fb97a0617baef216 Mon Sep 17 00:00:00 2001 From: CodingBunny06 <210141639+CodingBunny06@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:51:43 +0000 Subject: [PATCH 03/44] Improve FiberLab navigation and landing page Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app.py | 54 ++++++++++++++++++--- pages/attenuation_laboratory.py | 7 ++- pages/fiber_explorer.py | 7 ++- pages/home.py | 47 ++++++++++++++++++ pages/signal_propagation.py | 7 ++- requirements.txt | 2 +- tests/test_app_structure.py | 84 ++++++++++++++++++++++++++------- 7 files changed, 181 insertions(+), 27 deletions(-) create mode 100644 pages/home.py diff --git a/app.py b/app.py index 316e005..0439196 100644 --- a/app.py +++ b/app.py @@ -6,21 +6,61 @@ from pages.attenuation_laboratory import render as render_attenuation_laboratory from pages.fiber_explorer import render as render_fiber_explorer +from pages.home import render as render_home from pages.signal_propagation import render as render_signal_propagation -PAGE_RENDERERS = { - "Fiber Explorer": render_fiber_explorer, - "Attenuation Laboratory": render_attenuation_laboratory, - "Signal Propagation": render_signal_propagation, -} +PAGE_DEFINITIONS = ( + { + "render": render_home, + "title": "FiberLab", + "icon": ":material/home:", + "url_path": "home", + "default": True, + }, + { + "render": render_fiber_explorer, + "title": "Fiber Explorer", + "icon": ":material/explore:", + "url_path": "fiber-explorer", + }, + { + "render": render_attenuation_laboratory, + "title": "Attenuation Laboratory", + "icon": ":material/science:", + "url_path": "attenuation-laboratory", + }, + { + "render": render_signal_propagation, + "title": "Signal Propagation", + "icon": ":material/graphic_eq:", + "url_path": "signal-propagation", + }, +) + + +def build_navigation(): + """Build the native Streamlit navigation entries.""" + return st.navigation( + [ + st.Page( + definition["render"], + title=definition["title"], + icon=definition["icon"], + url_path=definition["url_path"], + default=definition.get("default", False), + ) + for definition in PAGE_DEFINITIONS + ], + position="sidebar", + ) def main() -> None: """Configure and render the Streamlit application.""" st.set_page_config(page_title="FiberLab", page_icon="πŸ”¬", layout="wide") - selected_page = st.sidebar.radio("Navigation", tuple(PAGE_RENDERERS.keys())) - PAGE_RENDERERS[selected_page]() + navigation = build_navigation() + navigation.run() if __name__ == "__main__": diff --git a/pages/attenuation_laboratory.py b/pages/attenuation_laboratory.py index 4e83eb1..0fc47c3 100644 --- a/pages/attenuation_laboratory.py +++ b/pages/attenuation_laboratory.py @@ -6,6 +6,11 @@ def render() -> None: """Render the Attenuation Laboratory page.""" st.title("Attenuation Laboratory") + st.caption("Investigate how optical loss shapes a transmission system.") st.write( - "Use this section to experiment with loss models and compare attenuation profiles." + "This laboratory will provide a focused workspace for comparing attenuation " + "profiles and studying the engineering impact of loss." ) + st.divider() + st.subheader("Laboratory workspace") + st.info("Interactive attenuation tools are coming soon.") diff --git a/pages/fiber_explorer.py b/pages/fiber_explorer.py index 319bd99..1b6705b 100644 --- a/pages/fiber_explorer.py +++ b/pages/fiber_explorer.py @@ -6,6 +6,11 @@ def render() -> None: """Render the Fiber Explorer page.""" st.title("Fiber Explorer") + st.caption("Inspect the building blocks of an optical fiber.") st.write( - "Use this section to investigate fiber geometry, refractive indices, and guiding conditions." + "This laboratory is reserved for guided exploration of fiber geometry, " + "refractive indices, and light-guiding conditions." ) + st.divider() + st.subheader("Laboratory workspace") + st.info("Interactive fiber exploration tools are coming soon.") diff --git a/pages/home.py b/pages/home.py new file mode 100644 index 0000000..ced4782 --- /dev/null +++ b/pages/home.py @@ -0,0 +1,47 @@ +"""FiberLab landing page.""" + +import streamlit as st + + +def render() -> None: + """Render the FiberLab landing page.""" + st.title("FiberLab") + st.subheader("A virtual optical fiber laboratory") + st.write( + "FiberLab is a focused engineering workspace for understanding how optical " + "fiber systems behave. Use the navigation to move between guided laboratory " + "experiences as the simulator grows." + ) + + st.divider() + st.markdown("### Version 1.0 capabilities") + capability_columns = st.columns(3) + capabilities = ( + ( + capability_columns[0], + ":material/explore:", + "Fiber Explorer", + "Review the geometry and guiding concepts that define an optical fiber.", + ), + ( + capability_columns[1], + ":material/science:", + "Attenuation Laboratory", + "A dedicated workspace for investigating optical loss and attenuation.", + ), + ( + capability_columns[2], + ":material/graphic_eq:", + "Signal Propagation", + "Explore the concepts behind signal travel, dispersion, and transmission.", + ), + ) + for column, icon, title, description in capabilities: + with column: + st.markdown(f"#### {icon} {title}") + st.caption(description) + + st.info( + "The v1.0 interface establishes the laboratory workflow. Interactive " + "physics models and visualizations will be added in future iterations." + ) diff --git a/pages/signal_propagation.py b/pages/signal_propagation.py index b834e98..98968ce 100644 --- a/pages/signal_propagation.py +++ b/pages/signal_propagation.py @@ -6,6 +6,11 @@ def render() -> None: """Render the Signal Propagation page.""" st.title("Signal Propagation") + st.caption("Follow the journey of an optical signal through the fiber.") st.write( - "Use this section to explore signal behavior, dispersion, and transmission distance." + "This laboratory will provide a focused workspace for exploring signal " + "behavior, dispersion, and transmission distance." ) + st.divider() + st.subheader("Laboratory workspace") + st.info("Interactive signal propagation tools are coming soon.") diff --git a/requirements.txt b/requirements.txt index 3eafa03..4037a80 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ numpy scipy -streamlit +streamlit==1.67.0 plotly matplotlib pytest \ No newline at end of file diff --git a/tests/test_app_structure.py b/tests/test_app_structure.py index 8f4ba4b..e21de3d 100644 --- a/tests/test_app_structure.py +++ b/tests/test_app_structure.py @@ -1,10 +1,14 @@ import importlib +from pathlib import Path import app +import pytest +from streamlit.testing.v1 import AppTest def test_page_modules_import_cleanly(): for module_name in ( + "pages.home", "pages.fiber_explorer", "pages.attenuation_laboratory", "pages.signal_propagation", @@ -13,32 +17,80 @@ def test_page_modules_import_cleanly(): assert hasattr(module, "render") -def test_navigation_registry_contains_all_pages(): - assert set(app.PAGE_RENDERERS.keys()) == { +def test_navigation_definitions_cover_all_pages(): + assert [definition["title"] for definition in app.PAGE_DEFINITIONS] == [ + "FiberLab", "Fiber Explorer", "Attenuation Laboratory", "Signal Propagation", - } + ] + assert sum(definition.get("default", False) for definition in app.PAGE_DEFINITIONS) == 1 -def test_main_renders_selected_page(monkeypatch): +def test_main_uses_native_sidebar_navigation(monkeypatch): calls = {} - monkeypatch.setattr(app.st, "set_page_config", lambda **kwargs: calls.setdefault("config", kwargs)) - - class Sidebar: - @staticmethod - def radio(*_args, **_kwargs): - return "Signal Propagation" - - monkeypatch.setattr(app.st, "sidebar", Sidebar()) monkeypatch.setattr( - app, - "PAGE_RENDERERS", - {"Signal Propagation": lambda: calls.setdefault("rendered", True)}, + app.st, + "set_page_config", + lambda **kwargs: calls.setdefault("config", kwargs), + ) + monkeypatch.setattr( + app.st, + "Page", + lambda render, **kwargs: {"render": render, **kwargs}, + ) + monkeypatch.setattr( + app.st, + "navigation", + lambda pages, **kwargs: calls.update( + {"pages": pages, "navigation_kwargs": kwargs} + ) + or type("Navigation", (), {"run": lambda self: calls.setdefault("ran", True)})(), ) app.main() assert calls["config"]["page_title"] == "FiberLab" - assert calls["rendered"] is True + assert calls["navigation_kwargs"] == {"position": "sidebar"} + assert [page["title"] for page in calls["pages"]] == [ + "FiberLab", + "Fiber Explorer", + "Attenuation Laboratory", + "Signal Propagation", + ] + assert calls["ran"] is True + + +def test_landing_page_renders(): + app_test = AppTest.from_file(Path(__file__).parents[1] / "app.py").run() + + assert not app_test.exception + assert [element.value for element in app_test.title] == ["FiberLab"] + assert any("Version 1.0 capabilities" in element.value for element in app_test.markdown) + + +@pytest.mark.parametrize( + ("module_name", "title"), + ( + ("pages.fiber_explorer", "Fiber Explorer"), + ("pages.attenuation_laboratory", "Attenuation Laboratory"), + ("pages.signal_propagation", "Signal Propagation"), + ), +) +def test_simulator_pages_render_placeholders(monkeypatch, module_name, title): + module = importlib.import_module(module_name) + rendered = {} + + monkeypatch.setattr(module.st, "title", lambda value: rendered.setdefault("title", value)) + monkeypatch.setattr(module.st, "caption", lambda value: rendered.setdefault("caption", value)) + monkeypatch.setattr(module.st, "write", lambda value: rendered.setdefault("body", value)) + monkeypatch.setattr(module.st, "divider", lambda: None) + monkeypatch.setattr(module.st, "subheader", lambda value: rendered.setdefault("subheader", value)) + monkeypatch.setattr(module.st, "info", lambda value: rendered.setdefault("info", value)) + + module.render() + + assert rendered["title"] == title + assert rendered["subheader"] == "Laboratory workspace" + assert "coming soon" in rendered["info"] From fb4e35b97d47aa24dd46ac9b499760ecbbd24294 Mon Sep 17 00:00:00 2001 From: CodingBunny06 <210141639+CodingBunny06@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:34:56 +0530 Subject: [PATCH 04/44] ci: add automated GitHub Actions test workflow --- .github/workflows/ci.yml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f729ca8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ + +name: FiberLab CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +permissions: + contents: read + +jobs: + test: + name: Run automated tests + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run tests + run: python -m pytest -v From ee8f34789a9b353e7656e39361847c83efa0610f Mon Sep 17 00:00:00 2001 From: CodingBunny06 <210141639+CodingBunny06@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:42:42 +0530 Subject: [PATCH 05/44] ci: integrate Ruff code quality checks --- .github/workflows/ci.yml | 5 ++++- requirements.txt | 3 ++- tests/test_app_structure.py | 3 ++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f729ca8..80e7562 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ permissions: jobs: test: - name: Run automated tests + name: Tests and code quality runs-on: ubuntu-latest steps: @@ -30,5 +30,8 @@ jobs: python -m pip install --upgrade pip pip install -r requirements.txt + - name: Run Ruff + run: ruff check . + - name: Run tests run: python -m pytest -v diff --git a/requirements.txt b/requirements.txt index 4037a80..fda743d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,5 @@ scipy streamlit==1.67.0 plotly matplotlib -pytest \ No newline at end of file +pytest +ruff \ No newline at end of file diff --git a/tests/test_app_structure.py b/tests/test_app_structure.py index e21de3d..b4a929c 100644 --- a/tests/test_app_structure.py +++ b/tests/test_app_structure.py @@ -1,10 +1,11 @@ import importlib from pathlib import Path -import app import pytest from streamlit.testing.v1 import AppTest +import app + def test_page_modules_import_cleanly(): for module_name in ( From c141b77a4c3bb92daa879b2ceeb63acbc485121d Mon Sep 17 00:00:00 2001 From: CodingBunny06 <210141639+CodingBunny06@users.noreply.github.com> Date: Mon, 9 Nov 2026 21:23:20 +0530 Subject: [PATCH 06/44] feat: build FiberLab landing page --- pages/home.py | 81 +++++++++++++++++++++++++------------ tests/test_app_structure.py | 3 +- 2 files changed, 56 insertions(+), 28 deletions(-) diff --git a/pages/home.py b/pages/home.py index ced4782..88a1449 100644 --- a/pages/home.py +++ b/pages/home.py @@ -1,3 +1,4 @@ + """FiberLab landing page.""" import streamlit as st @@ -5,43 +6,71 @@ def render() -> None: """Render the FiberLab landing page.""" + st.title("FiberLab") - st.subheader("A virtual optical fiber laboratory") - st.write( - "FiberLab is a focused engineering workspace for understanding how optical " - "fiber systems behave. Use the navigation to move between guided laboratory " - "experiences as the simulator grows." + st.subheader("Interactive Optical Fiber Communication Simulator") + + st.markdown( + """ + Explore the science behind optical fiber communication + through interactive experiments, mathematical models, + and scientific visualizations. + """ ) st.divider() - st.markdown("### Version 1.0 capabilities") - capability_columns = st.columns(3) - capabilities = ( + + st.markdown("### Explore the Laboratory") + + capabilities = [ + ( + "πŸ”­ Fiber Explorer", ( - capability_columns[0], - ":material/explore:", - "Fiber Explorer", - "Review the geometry and guiding concepts that define an optical fiber.", + "Understand optical fiber geometry, refractive indices, " + "numerical aperture, acceptance angle, and total internal reflection." ), + ), + ( + "πŸ“‰ Attenuation Laboratory", ( - capability_columns[1], - ":material/science:", - "Attenuation Laboratory", - "A dedicated workspace for investigating optical loss and attenuation.", + "Investigate optical power loss over fiber length and " + "study how attenuation affects signal strength." ), + ), + ( + "〰️ Signal Propagation", ( - capability_columns[2], - ":material/graphic_eq:", - "Signal Propagation", - "Explore the concepts behind signal travel, dispersion, and transmission.", + "Explore signal transmission, waveform behaviour, " + "and the fundamentals of pulse broadening." ), + ), + ] + + columns = st.columns(3) + + for column, (title, description) in zip(columns, capabilities): + with column, st.container(border=True): + st.markdown(f"#### {title}") + st.write(description) + + st.divider() + + st.markdown("### About FiberLab") + + st.write( + """ + FiberLab is an educational engineering platform designed + to make optical fiber concepts easier to understand. + + Our goal is to connect theoretical physics with practical + simulation through calculations, graphs, and interactive + laboratory experiences. + """ ) - for column, icon, title, description in capabilities: - with column: - st.markdown(f"#### {icon} {title}") - st.caption(description) st.info( - "The v1.0 interface establishes the laboratory workflow. Interactive " - "physics models and visualizations will be added in future iterations." + "🚧 Version 1.0 β€” Under Development. " + "Core physics models and interactive simulations are being built." ) + + st.caption("FiberLab | Optical Fiber Communication Laboratory") diff --git a/tests/test_app_structure.py b/tests/test_app_structure.py index b4a929c..d99123f 100644 --- a/tests/test_app_structure.py +++ b/tests/test_app_structure.py @@ -68,8 +68,7 @@ def test_landing_page_renders(): assert not app_test.exception assert [element.value for element in app_test.title] == ["FiberLab"] - assert any("Version 1.0 capabilities" in element.value for element in app_test.markdown) - + assert any("Explore the Laboratory" in element.value for element in app_test.markdown) @pytest.mark.parametrize( ("module_name", "title"), From 683a1d1692e4ee492c3ea766db588f67ba994985 Mon Sep 17 00:00:00 2001 From: CodingBunny06 <210141639+CodingBunny06@users.noreply.github.com> Date: Mon, 9 Nov 2026 21:44:44 +0530 Subject: [PATCH 07/44] refactor: standardize laboratory page layouts --- components/__init__.py | 0 components/page_layout.py | 17 +++++++++++++++++ pages/attenuation_laboratory.py | 21 ++++++++++++--------- pages/fiber_explorer.py | 23 ++++++++++++++--------- pages/signal_propagation.py | 21 ++++++++++++--------- tests/test_app_structure.py | 14 ++++++++------ 6 files changed, 63 insertions(+), 33 deletions(-) create mode 100644 components/__init__.py create mode 100644 components/page_layout.py diff --git a/components/__init__.py b/components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/components/page_layout.py b/components/page_layout.py new file mode 100644 index 0000000..f802322 --- /dev/null +++ b/components/page_layout.py @@ -0,0 +1,17 @@ +"""Reusable page layout components for FiberLab.""" + +import streamlit as st + + +def render_page_header(title: str, caption: str, description: str) -> None: + """Render a consistent header across laboratory pages.""" + st.title(title) + st.caption(caption) + st.write(description) + st.divider() + + +def render_workspace(message: str) -> None: + """Render a standard laboratory workspace section.""" + st.subheader("Laboratory workspace") + st.info(message) \ No newline at end of file diff --git a/pages/attenuation_laboratory.py b/pages/attenuation_laboratory.py index 0fc47c3..7b48762 100644 --- a/pages/attenuation_laboratory.py +++ b/pages/attenuation_laboratory.py @@ -1,16 +1,19 @@ """Attenuation Laboratory page.""" -import streamlit as st +from components.page_layout import render_page_header, render_workspace def render() -> None: """Render the Attenuation Laboratory page.""" - st.title("Attenuation Laboratory") - st.caption("Investigate how optical loss shapes a transmission system.") - st.write( - "This laboratory will provide a focused workspace for comparing attenuation " - "profiles and studying the engineering impact of loss." + render_page_header( + title="Attenuation Laboratory", + caption="Investigate how optical loss shapes a transmission system.", + description=( + "Study optical power loss, attenuation profiles, " + "and their impact on signal strength over fiber length." + ), ) - st.divider() - st.subheader("Laboratory workspace") - st.info("Interactive attenuation tools are coming soon.") + + render_workspace( + "Interactive attenuation tools are coming soon." + ) \ No newline at end of file diff --git a/pages/fiber_explorer.py b/pages/fiber_explorer.py index 1b6705b..eaa38fc 100644 --- a/pages/fiber_explorer.py +++ b/pages/fiber_explorer.py @@ -1,16 +1,21 @@ """Fiber Explorer page.""" -import streamlit as st + + +from components.page_layout import render_page_header, render_workspace def render() -> None: """Render the Fiber Explorer page.""" - st.title("Fiber Explorer") - st.caption("Inspect the building blocks of an optical fiber.") - st.write( - "This laboratory is reserved for guided exploration of fiber geometry, " - "refractive indices, and light-guiding conditions." + render_page_header( + title="Fiber Explorer", + caption="Inspect the building blocks of an optical fiber.", + description=( + "Explore fiber geometry, refractive indices, " + "and the conditions that allow light to travel through an optical fiber." + ), ) - st.divider() - st.subheader("Laboratory workspace") - st.info("Interactive fiber exploration tools are coming soon.") + + render_workspace( + "Interactive fiber exploration tools are coming soon." + ) \ No newline at end of file diff --git a/pages/signal_propagation.py b/pages/signal_propagation.py index 98968ce..99dda33 100644 --- a/pages/signal_propagation.py +++ b/pages/signal_propagation.py @@ -1,16 +1,19 @@ """Signal Propagation page.""" -import streamlit as st +from components.page_layout import render_page_header, render_workspace def render() -> None: """Render the Signal Propagation page.""" - st.title("Signal Propagation") - st.caption("Follow the journey of an optical signal through the fiber.") - st.write( - "This laboratory will provide a focused workspace for exploring signal " - "behavior, dispersion, and transmission distance." + render_page_header( + title="Signal Propagation", + caption="Follow the journey of an optical signal through the fiber.", + description=( + "Explore signal transmission, waveform behaviour, " + "and the fundamentals of pulse broadening and dispersion." + ), ) - st.divider() - st.subheader("Laboratory workspace") - st.info("Interactive signal propagation tools are coming soon.") + + render_workspace( + "Interactive signal propagation tools are coming soon." + ) \ No newline at end of file diff --git a/tests/test_app_structure.py b/tests/test_app_structure.py index d99123f..0f49f5a 100644 --- a/tests/test_app_structure.py +++ b/tests/test_app_structure.py @@ -82,12 +82,14 @@ def test_simulator_pages_render_placeholders(monkeypatch, module_name, title): module = importlib.import_module(module_name) rendered = {} - monkeypatch.setattr(module.st, "title", lambda value: rendered.setdefault("title", value)) - monkeypatch.setattr(module.st, "caption", lambda value: rendered.setdefault("caption", value)) - monkeypatch.setattr(module.st, "write", lambda value: rendered.setdefault("body", value)) - monkeypatch.setattr(module.st, "divider", lambda: None) - monkeypatch.setattr(module.st, "subheader", lambda value: rendered.setdefault("subheader", value)) - monkeypatch.setattr(module.st, "info", lambda value: rendered.setdefault("info", value)) + import streamlit as st + + monkeypatch.setattr(st, "title", lambda value: rendered.setdefault("title", value)) + monkeypatch.setattr(st, "caption", lambda value: rendered.setdefault("caption", value)) + monkeypatch.setattr(st, "write", lambda value: rendered.setdefault("body", value)) + monkeypatch.setattr(st, "divider", lambda: None) + monkeypatch.setattr(st, "subheader", lambda value: rendered.setdefault("subheader", value)) + monkeypatch.setattr(st, "info", lambda value: rendered.setdefault("info", value)) module.render() From a4bd1f8a89c0b01cf26987a551a5cd67e981e794 Mon Sep 17 00:00:00 2001 From: CodingBunny06 <210141639+CodingBunny06@users.noreply.github.com> Date: Mon, 9 Nov 2026 22:07:18 +0530 Subject: [PATCH 08/44] docs: define module integration contracts --- docs/integration/MODULE_CONTRACTS.md | 309 +++++++++++++++++++++++++++ 1 file changed, 309 insertions(+) create mode 100644 docs/integration/MODULE_CONTRACTS.md diff --git a/docs/integration/MODULE_CONTRACTS.md b/docs/integration/MODULE_CONTRACTS.md new file mode 100644 index 0000000..fed7aaf --- /dev/null +++ b/docs/integration/MODULE_CONTRACTS.md @@ -0,0 +1,309 @@ + +# FiberLab Module Integration Contracts + +## 1. Purpose + +This document defines how the physics, visualization, and Streamlit application modules communicate. + +All modules must follow these conventions to ensure smooth integration. + +## 2. General Conventions + +- Python is the implementation language. +- SI units are used internally wherever practical. +- UI input conversions must happen at the application boundary. +- Physics calculations must not depend on Streamlit. +- Visualization functions must receive calculated data rather than independently implementing physics. +- Functions must have clear names, type hints, and docstrings. +- Invalid inputs must raise appropriate exceptions. + +## 3. Physics Module Contract + +Location: `physics/` + +Responsibilities: +- Optical fiber calculations. +- Numerical aperture and acceptance angle. +- Critical angle and total internal reflection. +- Optical attenuation. +- Basic signal propagation and pulse broadening. + +Expected conventions: +- Functions accept numerical inputs. +- Functions return numerical results or structured result objects. +- Internal units must be documented. +- Physics calculations must be independently testable. + +## 4. Visualization Module Contract + +Location: `visualization/` + +Responsibilities: +- Fiber ray visualizations. +- Optical power graphs. +- Signal waveform graphs. +- Pulse broadening visualizations. + +Expected conventions: +- Visualization functions receive inputs or results from the physics module. +- Plotly figures should be returned for Streamlit rendering. +- Visualization code must not duplicate physics calculations. + +## 5. Application Integration Contract + +Location: `app.py`, `pages/`, and `components/` + +Responsibilities: +- Collect user inputs. +- Validate UI values. +- Convert UI units into internal units. +- Call physics functions. +- Pass calculated results to visualization functions. +- Display results and explanations. + +Streamlit must remain in the application and presentation layers. + +## 6. Testing Requirements + +- Physics functions require unit tests. +- Visualization functions require appropriate output tests. +- Application pages require rendering and integration tests. +- All pull requests must pass Ruff and Pytest checks. + +## 7. Collaboration Rules + +- Each team member works on their assigned feature branch. +- Shared interfaces must be agreed upon before implementation. +- Changes must be submitted through pull requests. +- Do not modify another member's assigned modules without coordination. + + +## 8. Physics API β€” Function Specifications + +The physics module provides numerical calculations independently of Streamlit. + +### 8.1 Numerical Aperture + +File: `physics/numerical_aperture.py` + +Function: +```python +def calculate_numerical_aperture( + n_core: float, + n_cladding: float +) -> float: + ... +``` + +Inputs: +- `n_core`: Core refractive index (dimensionless). +- `n_cladding`: Cladding refractive index (dimensionless). + +Output: +- Numerical aperture (dimensionless). + +Assumption: +- The v1.0 calculation assumes the external medium is air + (refractive index n_external = 1.0). +- Therefore, the numerical aperture is calculated as: + + NA = sqrt(n_coreΒ² - n_claddingΒ²) + +- Supporting other external media may be considered in a future version. + +### 8.2 Critical Angle + +File: `physics/numerical_aperture.py` + +Function: +```python +def calculate_critical_angle( + n_core: float, + n_cladding: float +) -> float: + ... +``` + +Output: +- Critical angle in radians. + +### 8.3 Attenuation + +File: `physics/attenuation.py` + +Function: +```python +def calculate_output_power( + input_power_mw: float, + attenuation_db_per_km: float, + length_km: float +) -> float: + ... +``` + +Output: +- Output optical power in mW. + +### 8.4 Total Power Loss + +File: `physics/attenuation.py` + +Function: +```python +def calculate_total_loss( + attenuation_db_per_km: float, + length_km: float +) -> float: + ... +``` + +Output: +- Total attenuation in dB. + +### 8.5 General Requirements + +- Validate all input values. +- Require `n_core > n_cladding > 0`. +- Require positive optical power and fiber length. +- Require non-negative attenuation. +- Raise `ValueError` for invalid inputs. +- Use radians internally for angle calculations. +- Include type hints and docstrings. +- Write independent unit tests for every function. + +These signatures define the proposed v1.0 interface. Implementation remains the responsibility of the physics module owner. + + +## 9. Visualization API β€” Function Specifications + +The visualization module converts simulation data into interactive visual outputs. + +### 9.1 Fiber Ray Visualization + +File: `visualization/fiber_animation.py` + +Proposed function: +```python +def create_fiber_ray_figure(ray_data): + ... + +### 9.2 Attenuation Graph + +File: `visualization/signal_graphs.py` + +Proposed function: +```python +def create_attenuation_figure( + distance_km, + power_mw +): + ... + +### 9.3 Signal Waveform Graph + +File: `visualization/signal_graphs.py` + +Proposed function: +```python +def create_signal_waveform_figure( + input_signal, + output_signal, + time_axis +): + ... +``` + +Output: +- Plotly Figure comparing input and output waveforms. + +Additional requirements: +- Input waveform, output waveform, and time-axis arrays + must have compatible lengths. +- The exact data types and units must be agreed upon + before implementation. + +### 9.4 General Requirements + +- Use Plotly for interactive graphs. +- Return Plotly Figure objects. +- Do not implement independent physics calculations. +- Receive calculated values or simulation data from the physics module. +- Include axis labels, units, legends and meaningful titles. +- Handle empty or invalid input data appropriately. +- Include tests for figure creation and output structure. + +These signatures are proposed v1.0 interfaces. The visualization module owner must review and agree to them before implementation. + + +## 10. Application Integration API + +The application layer connects user inputs, physics calculations, and visualizations. + +### 10.1 Responsibilities + +The application layer must: + +1. Collect inputs from the user through Streamlit. +2. Validate input values. +3. Convert UI units into internal units. +4. Call the relevant physics functions. +5. Pass calculated results to visualization functions. +6. Display numerical results, graphs, and explanations. + +### 10.2 Fiber Explorer + +Page: `pages/fiber_explorer.py` + +Integration flow: + +User inputs +β†’ Physics calculations +β†’ Numerical results +β†’ Fiber visualization +β†’ Streamlit display + +Expected calculations: +- Numerical aperture +- Critical angle +- Acceptance angle +- Total internal reflection conditions + +### 10.3 Attenuation Laboratory + +Page: `pages/attenuation_laboratory.py` + +Integration flow: + +Input power and fiber parameters +β†’ Attenuation calculations +β†’ Output power and total loss +β†’ Attenuation graph +β†’ Streamlit display + +### 10.4 Signal Propagation + +Page: `pages/signal_propagation.py` + +Integration flow: + +Input waveform and fiber parameters +β†’ Signal propagation model +β†’ Output waveform +β†’ Input/output comparison graph +β†’ Streamlit display + +### 10.5 Error Handling + +- Display understandable error messages for invalid user inputs. +- Do not allow invalid values to reach physics calculations. +- Handle calculation errors without crashing the application. +- Keep error handling separate from physics calculations. + +### 10.6 Architectural Rules + +- `app.py` controls application navigation. +- `pages/` handles user interaction and orchestration. +- `physics/` handles scientific calculations. +- `visualization/` handles graphical representations. +- `components/` contains reusable UI components. +- Physics and visualization modules must not depend on Streamlit. From d6954a67c3d1ff0561c69d8989660e3188ec65f8 Mon Sep 17 00:00:00 2001 From: CodingBunny06 <210141639+CodingBunny06@users.noreply.github.com> Date: Wed, 11 Nov 2026 13:08:35 +0530 Subject: [PATCH 09/44] docs: add developer guide --- docs/DEVELOPER_GUIDE.md | 196 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 docs/DEVELOPER_GUIDE.md diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md new file mode 100644 index 0000000..8a9bd13 --- /dev/null +++ b/docs/DEVELOPER_GUIDE.md @@ -0,0 +1,196 @@ + +# FiberLab Developer Guide + +## 1. Project Overview + +FiberLab is an interactive Optical Fiber Communication Simulator built using Python and Streamlit. + +It helps users explore optical fiber fundamentals, attenuation, and basic signal propagation through interactive simulations. + +## 2. Technology Stack + +- Python β€” Core programming language +- Streamlit β€” Web application framework +- NumPy and SciPy β€” Numerical computing +- Plotly β€” Interactive visualizations +- Matplotlib β€” Scientific plotting +- Pytest β€” Automated testing +- Ruff β€” Code quality and linting +- Git and GitHub β€” Version control and collaboration + +## 3. Project Architecture + +FiberLab follows a modular architecture that separates application logic, physics calculations, visualization, and reusable UI components. + + +## 4. Project Folder Structure + +```text +FiberLab/ +β”œβ”€β”€ app.py +β”œβ”€β”€ components/ +β”œβ”€β”€ pages/ +β”œβ”€β”€ physics/ +β”œβ”€β”€ visualization/ +β”œβ”€β”€ tests/ +β”œβ”€β”€ docs/ +β”‚ └── integration/ +β”œβ”€β”€ .github/ +β”‚ └── workflows/ +β”œβ”€β”€ requirements.txt +β”œβ”€β”€ README.md +└── .gitignore +``` + +### 4.1 Folder Responsibilities + +- `app.py`: Application entry point and navigation. +- `components/`: Reusable Streamlit UI components. +- `pages/`: Individual simulator pages. +- `physics/`: Scientific calculations and mathematical models. +- `visualization/`: Interactive graphs and visual simulations. +- `tests/`: Automated tests. +- `docs/`: Technical and developer documentation. +- `.github/workflows/`: GitHub Actions and CI configuration. + + +## 5. Development Environment Setup + +### Prerequisites + +- Python 3.12 or compatible version. +- Git. +- Visual Studio Code (recommended). + +### Clone the Repository + +```bash +git clone https://github.com/CodingBunny06/FiberLab.git +cd FiberLab +``` + +### Create a Virtual Environment + +```bash +python3 -m venv .venv +``` + +### Activate the Environment + +macOS/Linux: + +```bash +source .venv/bin/activate +``` + +### Install Dependencies + +```bash +python -m pip install --upgrade pip +pip install -r requirements.txt +``` + + +## 6. Running FiberLab + +Start the application using: + +```bash +streamlit run app.py +``` + +Streamlit will provide a local URL to access the application in your browser. + +To stop the application, press `Ctrl + C` in the terminal. + + +## 7. Development Workflow + +All development work should follow the project's Git collaboration process. + +### 7.1 Branching Strategy + +- `main`: Stable integration branch. +- `feature/`: New features and development work. +- `fix/`: Bug fixes. +- `docs/`: Documentation changes. + +Create a feature branch before starting new work: + +```bash +git switch main +git pull origin main +git switch -c feature/your-feature-name +``` + +### 7.2 Development Process + +1. Understand the assigned task. +2. Implement the changes. +3. Run code quality checks. +4. Run automated tests. +5. Commit the changes. +6. Push the branch. +7. Open a pull request for review. + + +## 8. Testing and Code Quality + +Before submitting changes, run the following checks. + +### Run Ruff + +```bash +ruff check . +``` + +### Run Pytest + +```bash +python -m pytest -v +``` + +All checks must pass before submitting a pull request. + +New functionality should include appropriate automated tests. + + +## 9. Coding Conventions + +- Follow consistent Python naming conventions. +- Use meaningful variable and function names. +- Include type hints wherever practical. +- Write docstrings for functions and classes. +- Keep functions focused on a single responsibility. +- Avoid duplicating existing functionality. +- Keep physics calculations independent of Streamlit. +- Keep visualization logic separate from scientific calculations. +- Document units for physical quantities. + +## 10. Module Integration Guidelines + +FiberLab uses separate modules for scientific calculations and visualizations. + +### Physics Module + +- Responsible for optical fiber calculations. +- Uses documented physical units. +- Must not depend on Streamlit. +- Must include independent unit tests. + +### Visualization Module + +- Responsible for graphical representations. +- Uses Plotly for interactive figures. +- Receives calculated data from the physics module. +- Must not duplicate scientific calculations. + +### Application Layer + +- Collects and validates user inputs. +- Calls physics functions. +- Passes results to visualization functions. +- Displays results through Streamlit. + +Refer to `docs/integration/MODULE_CONTRACTS.md` +for detailed function specifications and integration agreements. \ No newline at end of file From 2502f49ef718b55f00c6fe37d018a1e7fac46187 Mon Sep 17 00:00:00 2001 From: CodingBunny06 <210141639+CodingBunny06@users.noreply.github.com> Date: Wed, 11 Nov 2026 13:15:15 +0530 Subject: [PATCH 10/44] feat: add reusable input validation utilities --- components/validation.py | 30 +++++++++++++++++++++++++ tests/test_validation.py | 47 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 components/validation.py create mode 100644 tests/test_validation.py diff --git a/components/validation.py b/components/validation.py new file mode 100644 index 0000000..4c53bee --- /dev/null +++ b/components/validation.py @@ -0,0 +1,30 @@ + +"""Input validation utilities for FiberLab.""" + +import math + + +def validate_positive(value: float, name: str) -> None: + """Ensure a value is finite and greater than zero.""" + if not math.isfinite(value) or value <= 0: + raise ValueError(f"{name} must be a finite positive number.") + + +def validate_non_negative(value: float, name: str) -> None: + """Ensure a value is finite and non-negative.""" + if not math.isfinite(value) or value < 0: + raise ValueError(f"{name} must be a finite non-negative number.") + + +def validate_refractive_indices( + n_core: float, + n_cladding: float, +) -> None: + """Validate core and cladding refractive indices.""" + validate_positive(n_core, "Core refractive index") + validate_positive(n_cladding, "Cladding refractive index") + + if n_core <= n_cladding: + raise ValueError( + "Core refractive index must be greater than cladding refractive index." + ) diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..d26129e --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,47 @@ + +"""Tests for FiberLab input validation.""" + +import pytest + +from components.validation import ( + validate_non_negative, + validate_positive, + validate_refractive_indices, +) + + +def test_validate_positive_accepts_valid_value(): + assert validate_positive(5.0, "Length") is None + + +def test_validate_positive_rejects_zero(): + with pytest.raises(ValueError): + validate_positive(0, "Length") + + +def test_validate_positive_rejects_negative(): + with pytest.raises(ValueError): + validate_positive(-2.0, "Length") + + +def test_validate_positive_rejects_infinity(): + with pytest.raises(ValueError): + validate_positive(float("inf"), "Length") + + +def test_validate_non_negative_accepts_zero(): + assert validate_non_negative(0, "Attenuation") is None + + +def test_validate_non_negative_rejects_negative(): + with pytest.raises(ValueError): + validate_non_negative(-1.0, "Attenuation") + + +def test_validate_refractive_indices_accepts_valid_values(): + assert validate_refractive_indices(1.5, 1.4) is None + + +def test_validate_refractive_indices_rejects_invalid_order(): + with pytest.raises(ValueError): + validate_refractive_indices(1.4, 1.5) From ace432b1855807b8601ab5ccdbbff05aab8b8be8 Mon Sep 17 00:00:00 2001 From: CodingBunny06 <210141639+CodingBunny06@users.noreply.github.com> Date: Wed, 11 Nov 2026 13:19:07 +0530 Subject: [PATCH 11/44] feat: add reusable simulation result display --- components/result_display.py | 17 +++++++++++++ tests/test_result_display.py | 46 ++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 components/result_display.py create mode 100644 tests/test_result_display.py diff --git a/components/result_display.py b/components/result_display.py new file mode 100644 index 0000000..1e3c9b6 --- /dev/null +++ b/components/result_display.py @@ -0,0 +1,17 @@ + +"""Reusable result display components for FiberLab.""" + +import streamlit as st + + +def render_result_metrics(results: dict[str, str]) -> None: + """Display simulation results as a row of metrics.""" + if not results: + st.info("No simulation results available yet.") + return + + columns = st.columns(len(results)) + + for column, (label, value) in zip(columns, results.items()): + with column: + st.metric(label=label, value=value) diff --git a/tests/test_result_display.py b/tests/test_result_display.py new file mode 100644 index 0000000..0fec570 --- /dev/null +++ b/tests/test_result_display.py @@ -0,0 +1,46 @@ + +"""Tests for FiberLab result display components.""" + +from unittest.mock import MagicMock + +from components import result_display + + +def test_render_result_metrics_displays_empty_message(monkeypatch): + info_mock = MagicMock() + monkeypatch.setattr(result_display.st, "info", info_mock) + + result_display.render_result_metrics({}) + + info_mock.assert_called_once_with( + "No simulation results available yet." + ) + + + +def test_render_result_metrics_displays_values(monkeypatch): + columns = [MagicMock(), MagicMock()] + columns_mock = MagicMock(return_value=columns) + metric_mock = MagicMock() + + monkeypatch.setattr(result_display.st, "columns", columns_mock) + monkeypatch.setattr(result_display.st, "metric", metric_mock) + + results = { + "Output Power": "12.5 mW", + "Total Loss": "3.2 dB", + } + + result_display.render_result_metrics(results) + + columns_mock.assert_called_once_with(2) + + assert metric_mock.call_count == 2 + assert metric_mock.call_args_list[0].kwargs == { + "label": "Output Power", + "value": "12.5 mW", + } + assert metric_mock.call_args_list[1].kwargs == { + "label": "Total Loss", + "value": "3.2 dB", + } From 63d972645404bcbbb6ff657abcf3f919e425f736 Mon Sep 17 00:00:00 2001 From: CodingBunny06 <210141639+CodingBunny06@users.noreply.github.com> Date: Wed, 11 Nov 2026 13:39:38 +0530 Subject: [PATCH 12/44] ci: introduce FiberLab Guardian quality gate --- .github/workflows/ci.yml | 28 +++++++++++-- tests/test_module_contracts.py | 73 ++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 tests/test_module_contracts.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80e7562..8b131be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,5 @@ -name: FiberLab CI +name: FiberLab Guardian on: pull_request: @@ -11,8 +11,8 @@ permissions: contents: read jobs: - test: - name: Tests and code quality + quality: + name: Code Quality runs-on: ubuntu-latest steps: @@ -33,5 +33,25 @@ jobs: - name: Run Ruff run: ruff check . - - name: Run tests + tests: + name: Automated Tests + runs-on: ubuntu-latest + needs: quality + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run all tests run: python -m pytest -v diff --git a/tests/test_module_contracts.py b/tests/test_module_contracts.py new file mode 100644 index 0000000..13f16fb --- /dev/null +++ b/tests/test_module_contracts.py @@ -0,0 +1,73 @@ + +"""Tests for FiberLab module integration contracts.""" + +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def test_physics_package_exists(): + assert (PROJECT_ROOT / "physics" / "__init__.py").exists() + + +def test_visualization_package_exists(): + assert (PROJECT_ROOT / "visualization" / "__init__.py").exists() + + +def test_integration_contract_document_exists(): + assert ( + PROJECT_ROOT / "docs" / "integration" / "MODULE_CONTRACTS.md" + ).exists() + + +def test_physics_package_does_not_import_streamlit(): + physics_files = list((PROJECT_ROOT / "physics").glob("*.py")) + + for file_path in physics_files: + source = file_path.read_text(encoding="utf-8") + assert "import streamlit" not in source + assert "from streamlit" not in source + + +def test_physics_api_contract_exists(): + contract = ( + PROJECT_ROOT / "docs" / "integration" / "MODULE_CONTRACTS.md" + ) + content = contract.read_text(encoding="utf-8") + + assert "calculate_numerical_aperture" in content + assert "calculate_critical_angle" in content + assert "calculate_output_power" in content + assert "calculate_total_loss" in content + + +def test_visualization_api_contract_exists(): + contract = ( + PROJECT_ROOT / "docs" / "integration" / "MODULE_CONTRACTS.md" + ) + content = contract.read_text(encoding="utf-8") + + assert "create_fiber_ray_figure" in content + assert "create_attenuation_figure" in content + assert "create_signal_waveform_figure" in content + + +def test_application_integration_contract_exists(): + contract = ( + PROJECT_ROOT / "docs" / "integration" / "MODULE_CONTRACTS.md" + ) + content = contract.read_text(encoding="utf-8") + + assert "Application Integration API" in content + assert "Error Handling" in content + + +def test_visualization_package_does_not_import_streamlit(): + visualization_files = list( + (PROJECT_ROOT / "visualization").glob("*.py") + ) + + for file_path in visualization_files: + source = file_path.read_text(encoding="utf-8") + assert "import streamlit" not in source + assert "from streamlit" not in source From fe457d403c7046cd05352c9fdc34b294a19b3ef2 Mon Sep 17 00:00:00 2001 From: CodingBunny06 <210141639+CodingBunny06@users.noreply.github.com> Date: Wed, 11 Nov 2026 13:43:23 +0530 Subject: [PATCH 13/44] ci: run Guardian on feature branches --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b131be..687bf33 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,9 @@ on: pull_request: branches: [main] push: - branches: [main] + branches: + - main + - "feature/**" permissions: contents: read From 216813756cf4f8b77068164ec4f6a8eec3553553 Mon Sep 17 00:00:00 2001 From: CodingBunny06 <210141639+CodingBunny06@users.noreply.github.com> Date: Wed, 11 Nov 2026 13:43:46 +0530 Subject: [PATCH 14/44] ci: run Guardian on feature branches --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 687bf33..50ff07b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,9 +5,9 @@ on: pull_request: branches: [main] push: - branches: - - main - - "feature/**" + branches: + - main + - "feature/**" permissions: contents: read From a740bad8c08d41b4c7dc8b365ae10e12f25db052 Mon Sep 17 00:00:00 2001 From: CodingBunny06 <210141639+CodingBunny06@users.noreply.github.com> Date: Wed, 11 Nov 2026 13:53:09 +0530 Subject: [PATCH 15/44] ci: validate teammate module API signatures --- tests/test_module_contracts.py | 127 ++++++++++++++++++++++++++++++--- 1 file changed, 118 insertions(+), 9 deletions(-) diff --git a/tests/test_module_contracts.py b/tests/test_module_contracts.py index 13f16fb..afc1e75 100644 --- a/tests/test_module_contracts.py +++ b/tests/test_module_contracts.py @@ -1,10 +1,50 @@ """Tests for FiberLab module integration contracts.""" +import ast from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parents[1] +# Expected public APIs and their parameter names, in order. +MODULE_CONTRACTS = { + "physics/numerical_aperture.py": { + "calculate_numerical_aperture": ( + "n_core", + "n_cladding", + ), + "calculate_critical_angle": ( + "n_core", + "n_cladding", + ), + }, + "physics/attenuation.py": { + "calculate_output_power": ( + "input_power_mw", + "attenuation_db_per_km", + "length_km", + ), + "calculate_total_loss": ( + "attenuation_db_per_km", + "length_km", + ), + }, + "visualization/fiber_animation.py": { + "create_fiber_ray_figure": ("ray_data",), + }, + "visualization/signal_graphs.py": { + "create_attenuation_figure": ( + "distance_km", + "power_mw", + ), + "create_signal_waveform_figure": ( + "input_signal", + "output_signal", + "time_axis", + ), + }, +} + def test_physics_package_exists(): assert (PROJECT_ROOT / "physics" / "__init__.py").exists() @@ -30,9 +70,7 @@ def test_physics_package_does_not_import_streamlit(): def test_physics_api_contract_exists(): - contract = ( - PROJECT_ROOT / "docs" / "integration" / "MODULE_CONTRACTS.md" - ) + contract = PROJECT_ROOT / "docs" / "integration" / "MODULE_CONTRACTS.md" content = contract.read_text(encoding="utf-8") assert "calculate_numerical_aperture" in content @@ -42,9 +80,7 @@ def test_physics_api_contract_exists(): def test_visualization_api_contract_exists(): - contract = ( - PROJECT_ROOT / "docs" / "integration" / "MODULE_CONTRACTS.md" - ) + contract = PROJECT_ROOT / "docs" / "integration" / "MODULE_CONTRACTS.md" content = contract.read_text(encoding="utf-8") assert "create_fiber_ray_figure" in content @@ -53,9 +89,7 @@ def test_visualization_api_contract_exists(): def test_application_integration_contract_exists(): - contract = ( - PROJECT_ROOT / "docs" / "integration" / "MODULE_CONTRACTS.md" - ) + contract = PROJECT_ROOT / "docs" / "integration" / "MODULE_CONTRACTS.md" content = contract.read_text(encoding="utf-8") assert "Application Integration API" in content @@ -71,3 +105,78 @@ def test_visualization_package_does_not_import_streamlit(): source = file_path.read_text(encoding="utf-8") assert "import streamlit" not in source assert "from streamlit" not in source + + +def test_implemented_module_api_signatures(): + """Validate implemented APIs without importing or executing modules.""" + + expected_locations = {} + + for relative_path, functions in MODULE_CONTRACTS.items(): + expected_path = PROJECT_ROOT / relative_path + + for function_name in functions: + expected_locations[function_name] = expected_path + + # Not implemented yet: allow the teammate to add it later. + if not expected_path.exists(): + continue + + source = expected_path.read_text(encoding="utf-8") + module = ast.parse(source, filename=str(expected_path)) + + definitions = { + node.name: node + for node in module.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + + for function_name, expected_parameters in functions.items(): + assert function_name in definitions, ( + f"{function_name} is missing from {relative_path}" + ) + + function = definitions[function_name] + actual_parameters = tuple( + argument.arg for argument in function.args.args + ) + + assert actual_parameters == expected_parameters, ( + f"{function_name} in {relative_path} has incorrect " + f"parameters. Expected {expected_parameters}, " + f"got {actual_parameters}." + ) + + assert not function.args.posonlyargs, ( + f"{function_name} must not use positional-only parameters." + ) + assert function.args.vararg is None, ( + f"{function_name} must not use *args." + ) + assert function.args.kwarg is None, ( + f"{function_name} must not use **kwargs." + ) + assert not function.args.kwonlyargs, ( + f"{function_name} must not use keyword-only parameters." + ) + + # Detect contract functions implemented in the wrong module. + for package in ("physics", "visualization"): + package_path = PROJECT_ROOT / package + + for file_path in package_path.glob("*.py"): + source = file_path.read_text(encoding="utf-8") + module = ast.parse(source, filename=str(file_path)) + + for node in module.body: + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + + expected_path = expected_locations.get(node.name) + + if expected_path is not None: + assert file_path == expected_path, ( + f"{node.name} belongs in " + f"{expected_path.relative_to(PROJECT_ROOT)}, " + f"not {file_path.relative_to(PROJECT_ROOT)}." + ) From d3f8c1053cee935ef75c73f29bae2435e772d16d Mon Sep 17 00:00:00 2001 From: CodingBunny06 <210141639+CodingBunny06@users.noreply.github.com> Date: Wed, 11 Nov 2026 13:58:20 +0530 Subject: [PATCH 16/44] test: add Guardian API validation self-check --- tests/test_guardian_self_check.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 tests/test_guardian_self_check.py diff --git a/tests/test_guardian_self_check.py b/tests/test_guardian_self_check.py new file mode 100644 index 0000000..4252c7c --- /dev/null +++ b/tests/test_guardian_self_check.py @@ -0,0 +1,25 @@ +import ast + + +def test_guardian_detects_incorrect_api_signature(): + # Simulate a teammate implementing the wrong signature. + incorrect_source = """ +def calculate_numerical_aperture(n_core, cladding_index): + return 0.0 +""" + + expected_parameters = ("n_core", "n_cladding") + + module = ast.parse(incorrect_source) + + function = next( + node for node in module.body + if isinstance(node, ast.FunctionDef) + ) + + actual_parameters = tuple( + argument.arg for argument in function.args.args + ) + + assert actual_parameters != expected_parameters + assert actual_parameters == ("n_core", "cladding_index") \ No newline at end of file From ec54aa0abef13944a000dd7c8b49d5a792f29881 Mon Sep 17 00:00:00 2001 From: CodingBunny06 <210141639+CodingBunny06@users.noreply.github.com> Date: Wed, 11 Nov 2026 14:48:11 +0530 Subject: [PATCH 17/44] feat: implement Guardian code inspector --- guardian/__init__.py | 0 guardian/inspector.py | 319 +++++++++++++++++++++++++++++++ tests/test_guardian_inspector.py | 193 +++++++++++++++++++ 3 files changed, 512 insertions(+) create mode 100644 guardian/__init__.py create mode 100644 guardian/inspector.py create mode 100644 tests/test_guardian_inspector.py diff --git a/guardian/__init__.py b/guardian/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/guardian/inspector.py b/guardian/inspector.py new file mode 100644 index 0000000..7359026 --- /dev/null +++ b/guardian/inspector.py @@ -0,0 +1,319 @@ + +"""Inspect FiberLab modules against their agreed integration contracts.""" + +import ast +from dataclasses import dataclass +from pathlib import Path + + +@dataclass +class InspectionFinding: + """Represent one result from a Guardian inspection.""" + + status: str + file: str + message: str + + +MODULE_CONTRACTS = { + "physics/numerical_aperture.py": { + "calculate_numerical_aperture": ("n_core", "n_cladding"), + "calculate_critical_angle": ("n_core", "n_cladding"), + }, + "physics/attenuation.py": { + "calculate_output_power": ( + "input_power_mw", + "attenuation_db_per_km", + "length_km", + ), + "calculate_total_loss": ( + "attenuation_db_per_km", + "length_km", + ), + }, + "visualization/fiber_animation.py": { + "create_fiber_ray_figure": ("ray_data",), + }, + "visualization/signal_graphs.py": { + "create_attenuation_figure": ("distance_km", "power_mw"), + "create_signal_waveform_figure": ( + "input_signal", + "output_signal", + "time_axis", + ), + }, +} + + +def inspect_project(project_root: Path) -> list[InspectionFinding]: + """Inspect physics and visualization modules against their contracts.""" + findings = [] + expected_locations = { + function_name: relative_path + for relative_path, functions in MODULE_CONTRACTS.items() + for function_name in functions + } + + for relative_path, expected_functions in MODULE_CONTRACTS.items(): + file_path = project_root / relative_path + + if not file_path.exists(): + findings.append( + InspectionFinding( + "PENDING", + relative_path, + "Module has not been implemented yet.", + ) + ) + continue + + try: + source = file_path.read_text(encoding="utf-8") + module = ast.parse(source, filename=str(file_path)) + except (SyntaxError, UnicodeDecodeError) as error: + findings.append( + InspectionFinding( + "FAIL", + relative_path, + f"Unable to parse module: {error}", + ) + ) + continue + + definitions = { + node.name: node + for node in module.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + + for function_name, expected_parameters in expected_functions.items(): + function = definitions.get(function_name) + + if function is None: + findings.append( + InspectionFinding( + "FAIL", + relative_path, + f"Missing required function: {function_name}.", + ) + ) + continue + + actual_parameters = tuple( + argument.arg for argument in function.args.args + ) + + if ( + actual_parameters != expected_parameters + or function.args.posonlyargs + or function.args.vararg + or function.args.kwarg + or function.args.kwonlyargs + ): + findings.append( + InspectionFinding( + "FAIL", + relative_path, + ( + f"{function_name} has an incompatible signature. " + f"Expected {expected_parameters}; " + f"got {actual_parameters}." + ), + ) + ) + else: + findings.append( + InspectionFinding( + "PASS", + relative_path, + f"{function_name} matches its expected signature.", + ) + ) + + # Check for contract functions implemented in the wrong module. + for package in ("physics", "visualization"): + package_path = project_root / package + + if not package_path.exists(): + continue + + for file_path in package_path.glob("*.py"): + try: + module = ast.parse( + file_path.read_text(encoding="utf-8"), + filename=str(file_path), + ) + except (SyntaxError, UnicodeDecodeError): + continue + + relative_path = file_path.relative_to(project_root).as_posix() + + for node in module.body: + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + + expected_path = expected_locations.get(node.name) + + if expected_path and relative_path != expected_path: + findings.append( + InspectionFinding( + "FAIL", + relative_path, + ( + f"{node.name} belongs in {expected_path}, " + f"not {relative_path}." + ), + ) + ) + + # Physics and visualization must remain independent of Streamlit. + for package in ("physics", "visualization"): + package_path = project_root / package + + if not package_path.exists(): + continue + + for file_path in package_path.glob("*.py"): + relative_path = file_path.relative_to(project_root).as_posix() + + try: + module = ast.parse( + file_path.read_text(encoding="utf-8"), + filename=str(file_path), + ) + except (SyntaxError, UnicodeDecodeError): + continue + + for node in ast.walk(module): + imports_streamlit = ( + isinstance(node, ast.Import) + and any(alias.name == "streamlit" for alias in node.names) + ) or ( + isinstance(node, ast.ImportFrom) + and node.module == "streamlit" + ) + + if imports_streamlit: + findings.append( + InspectionFinding( + "FAIL", + relative_path, + "Streamlit import is not allowed in this package.", + ) + ) + findings.extend(inspect_internal_imports(project_root)) + + return findings + + + +def inspect_internal_imports(project_root: Path) -> list[InspectionFinding]: + """Detect broken internal module and symbol imports.""" + findings = [] + project_packages = ("physics", "visualization", "components", "pages") + + for package in project_packages: + package_path = project_root / package + + if not package_path.exists(): + continue + + for file_path in package_path.rglob("*.py"): + relative_path = file_path.relative_to(project_root).as_posix() + + try: + module = ast.parse( + file_path.read_text(encoding="utf-8"), + filename=str(file_path), + ) + except (SyntaxError, UnicodeDecodeError): + continue + + for node in ast.walk(module): + if not isinstance(node, ast.ImportFrom) or not node.module: + continue + + imported_module = node.module + root_package = imported_module.split(".")[0] + + if root_package not in project_packages: + continue + + module_path = project_root.joinpath( + *imported_module.split(".") + ) + + target_file = module_path.with_suffix(".py") + target_init = module_path / "__init__.py" + + if target_file.exists(): + pass + elif target_init.exists(): + target_file = target_init + else: + findings.append( + InspectionFinding( + "FAIL", + relative_path, + f"Broken internal import: {imported_module}.", + ) + ) + continue + + try: + target_source = target_file.read_text(encoding="utf-8") + target_ast = ast.parse( + target_source, + filename=str(target_file), + ) + except (SyntaxError, UnicodeDecodeError): + continue + + available_symbols = { + item.name + for item in target_ast.body + if isinstance( + item, + ( + ast.FunctionDef, + ast.AsyncFunctionDef, + ast.ClassDef, + ), + ) + } + + available_symbols.update( + item.targets[0].id + for item in target_ast.body + if isinstance(item, ast.Assign) + and len(item.targets) == 1 + and isinstance(item.targets[0], ast.Name) + ) + + for alias in node.names: + if alias.name == "*": + continue + + # Allow imports of submodules. + submodule_path = module_path / f"{alias.name}.py" + submodule_package = module_path / alias.name / "__init__.py" + + if ( + alias.name in available_symbols + or submodule_path.exists() + or submodule_package.exists() + ): + continue + + findings.append( + InspectionFinding( + "FAIL", + relative_path, + ( + f"Missing imported symbol '{alias.name}' " + f"in module '{imported_module}'." + ), + ) + ) + + return findings diff --git a/tests/test_guardian_inspector.py b/tests/test_guardian_inspector.py new file mode 100644 index 0000000..34eb395 --- /dev/null +++ b/tests/test_guardian_inspector.py @@ -0,0 +1,193 @@ + +"""Tests for the FiberLab Guardian code inspector.""" + +from pathlib import Path + +from guardian.inspector import inspect_project + + +def create_module(root: Path, relative_path: str, source: str) -> None: + """Create a Python module inside a temporary project.""" + file_path = root / relative_path + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_text(source, encoding="utf-8") + + +def test_missing_modules_are_reported_as_pending(tmp_path): + findings = inspect_project(tmp_path) + + assert findings + assert all(finding.status == "PENDING" for finding in findings) + + +def test_valid_physics_module_passes_signature_check(tmp_path): + create_module( + tmp_path, + "physics/numerical_aperture.py", + """ +def calculate_numerical_aperture(n_core, n_cladding): + return 0.0 + +def calculate_critical_angle(n_core, n_cladding): + return 0.0 +""", + ) + + findings = inspect_project(tmp_path) + + assert any( + finding.status == "PASS" + and "calculate_numerical_aperture" in finding.message + for finding in findings + ) + + +def test_incorrect_signature_is_detected(tmp_path): + create_module( + tmp_path, + "physics/numerical_aperture.py", + """ +def calculate_numerical_aperture(n_core, cladding_index): + return 0.0 + +def calculate_critical_angle(n_core, n_cladding): + return 0.0 +""", + ) + + findings = inspect_project(tmp_path) + + assert any( + finding.status == "FAIL" + and "incompatible signature" in finding.message + for finding in findings + ) + + +def test_syntax_error_is_detected(tmp_path): + create_module( + tmp_path, + "physics/numerical_aperture.py", + "def calculate_numerical_aperture(:", + ) + + findings = inspect_project(tmp_path) + + assert any( + finding.status == "FAIL" + and "Unable to parse module" in finding.message + for finding in findings + ) + + +def test_streamlit_import_is_detected(tmp_path): + create_module( + tmp_path, + "physics/numerical_aperture.py", + """ +import streamlit + +def calculate_numerical_aperture(n_core, n_cladding): + return 0.0 + +def calculate_critical_angle(n_core, n_cladding): + return 0.0 +""", + ) + + findings = inspect_project(tmp_path) + + assert any( + finding.status == "FAIL" + and "Streamlit import" in finding.message + for finding in findings + ) + + +def test_missing_function_is_detected(tmp_path): + create_module( + tmp_path, + "physics/numerical_aperture.py", + """ +def calculate_numerical_aperture(n_core, n_cladding): + return 0.0 +""", + ) + + findings = inspect_project(tmp_path) + + assert any( + finding.status == "FAIL" + and "calculate_critical_angle" in finding.message + for finding in findings + ) + + +def test_function_in_wrong_module_is_detected(tmp_path): + create_module( + tmp_path, + "physics/attenuation.py", + """ +def calculate_numerical_aperture(n_core, n_cladding): + return 0.0 +""", + ) + + findings = inspect_project(tmp_path) + + assert any( + finding.status == "FAIL" + and "belongs in physics/numerical_aperture.py" in finding.message + for finding in findings + ) + + +def test_missing_internal_module_import_is_detected(tmp_path): + create_module( + tmp_path, + "physics/numerical_aperture.py", + """ +from physics.missing_module import calculate_something + +def calculate_numerical_aperture(n_core, n_cladding): + return 0.0 + +def calculate_critical_angle(n_core, n_cladding): + return 0.0 +""", + ) + + findings = inspect_project(tmp_path) + + assert any( + finding.status == "FAIL" + and "physics.missing_module" in finding.message + for finding in findings + ) + + +def test_missing_imported_function_is_detected(tmp_path): + create_module( + tmp_path, + "physics/attenuation.py", + """ +def calculate_output_power(input_power_mw, attenuation_db_per_km, length_km): + return 1.0 +""", + ) + + create_module( + tmp_path, + "visualization/signal_graphs.py", + """ +from physics.attenuation import calculate_loss +""", + ) + + findings = inspect_project(tmp_path) + + assert any( + finding.status == "FAIL" + and "calculate_loss" in finding.message + for finding in findings + ) From 1abca57616c6f2f85d9d6ed339be04cf59555090 Mon Sep 17 00:00:00 2001 From: CodingBunny06 <210141639+CodingBunny06@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:01:10 +0530 Subject: [PATCH 18/44] feat: build Guardian diagnostic pipeline --- guardian/error_detector.py | 93 +++++++++++++++++++++++++++ guardian/reporter.py | 70 ++++++++++++++++++++ tests/test_guardian_error_detector.py | 69 ++++++++++++++++++++ tests/test_guardian_pipeline.py | 53 +++++++++++++++ tests/test_guardian_reporter.py | 36 +++++++++++ 5 files changed, 321 insertions(+) create mode 100644 guardian/error_detector.py create mode 100644 guardian/reporter.py create mode 100644 tests/test_guardian_error_detector.py create mode 100644 tests/test_guardian_pipeline.py create mode 100644 tests/test_guardian_reporter.py diff --git a/guardian/error_detector.py b/guardian/error_detector.py new file mode 100644 index 0000000..8a6f709 --- /dev/null +++ b/guardian/error_detector.py @@ -0,0 +1,93 @@ + +"""Classify and explain findings produced by FiberLab Guardian.""" + +from dataclasses import dataclass + +from guardian.inspector import InspectionFinding + + +@dataclass +class Diagnostic: + """Represent an actionable Guardian diagnostic.""" + + status: str + severity: str + file: str + message: str + recommendation: str + + +def classify_finding(finding: InspectionFinding) -> Diagnostic: + """Convert an inspection finding into an actionable diagnostic.""" + if finding.status == "PASS": + return Diagnostic( + "PASS", + "INFO", + finding.file, + finding.message, + "No action required.", + ) + + if finding.status == "PENDING": + return Diagnostic( + "PENDING", + "WAITING", + finding.file, + finding.message, + "Wait for the module owner to submit the implementation.", + ) + + message = finding.message.lower() + + recommendations = ( + ( + "unable to parse module", + "Fix the Python syntax error in this file.", + ), + ( + "missing required function", + "Implement the missing function according to MODULE_CONTRACTS.md.", + ), + ( + "incompatible signature", + "Update the function parameters to match the agreed API contract.", + ), + ( + "broken internal import", + "Check the import path and ensure the target module exists.", + ), + ( + "missing imported symbol", + "Define the missing function or class in the imported module.", + ), + ( + "streamlit import", + "Move Streamlit-dependent code into the application layer.", + ), + ( + "belongs in", + "Move the function to the module specified by the integration contract.", + ), + ) + + recommendation = "Review the finding and compare the implementation with the integration contract." + + for keyword, action in recommendations: + if keyword in message: + recommendation = action + break + + return Diagnostic( + "FAIL", + "HIGH", + finding.file, + finding.message, + recommendation, + ) + + +def analyze_findings( + findings: list[InspectionFinding], +) -> list[Diagnostic]: + """Classify all findings produced by the Code Inspector.""" + return [classify_finding(finding) for finding in findings] diff --git a/guardian/reporter.py b/guardian/reporter.py new file mode 100644 index 0000000..8556982 --- /dev/null +++ b/guardian/reporter.py @@ -0,0 +1,70 @@ + +"""Generate consolidated reports from FiberLab Guardian inspections.""" + +from dataclasses import dataclass +from pathlib import Path + +from guardian.error_detector import Diagnostic, analyze_findings +from guardian.inspector import inspect_project + + +@dataclass +class GuardianReport: + """Store the complete results of a Guardian inspection.""" + + diagnostics: list[Diagnostic] + passed: int + failed: int + pending: int + + @property + def overall_status(self) -> str: + """Determine the overall inspection status.""" + if self.failed: + return "FAIL" + + if self.pending: + return "PENDING" + + return "PASS" + + +def generate_report(project_root: Path) -> GuardianReport: + """Run inspection, classify findings, and generate a report.""" + findings = inspect_project(project_root) + diagnostics = analyze_findings(findings) + + return GuardianReport( + diagnostics=diagnostics, + passed=sum(item.status == "PASS" for item in diagnostics), + failed=sum(item.status == "FAIL" for item in diagnostics), + pending=sum(item.status == "PENDING" for item in diagnostics), + ) + + +def format_report(report: GuardianReport) -> str: + """Format Guardian results into a readable text report.""" + lines = [ + "FIBERLAB GUARDIAN REPORT", + "=" * 30, + f"Overall Status: {report.overall_status}", + f"Passed: {report.passed}", + f"Failed: {report.failed}", + f"Pending: {report.pending}", + "", + "DIAGNOSTICS", + "-" * 30, + ] + + for diagnostic in report.diagnostics: + lines.extend( + [ + f"[{diagnostic.status}] {diagnostic.file}", + f"Severity: {diagnostic.severity}", + f"Finding: {diagnostic.message}", + f"Recommendation: {diagnostic.recommendation}", + "", + ] + ) + + return "\n".join(lines) diff --git a/tests/test_guardian_error_detector.py b/tests/test_guardian_error_detector.py new file mode 100644 index 0000000..9800db8 --- /dev/null +++ b/tests/test_guardian_error_detector.py @@ -0,0 +1,69 @@ + +"""Tests for the FiberLab Guardian error detector.""" + +from guardian.error_detector import analyze_findings, classify_finding +from guardian.inspector import InspectionFinding + + +def test_pass_finding_is_classified_as_info(): + finding = InspectionFinding("PASS", "physics/test.py", "API is valid.") + + diagnostic = classify_finding(finding) + + assert diagnostic.severity == "INFO" + assert diagnostic.recommendation == "No action required." + + +def test_pending_finding_is_classified_as_waiting(): + finding = InspectionFinding( + "PENDING", + "physics/test.py", + "Module is not implemented.", + ) + + diagnostic = classify_finding(finding) + + assert diagnostic.severity == "WAITING" + assert diagnostic.status == "PENDING" + + +def test_missing_function_gets_recommendation(): + finding = InspectionFinding( + "FAIL", + "physics/attenuation.py", + "Missing required function: calculate_output_power.", + ) + + diagnostic = classify_finding(finding) + + assert diagnostic.severity == "HIGH" + assert "Implement the missing function" in diagnostic.recommendation + + +def test_broken_import_gets_recommendation(): + finding = InspectionFinding( + "FAIL", + "visualization/signal_graphs.py", + "Broken internal import: physics.missing_module.", + ) + + diagnostic = classify_finding(finding) + + assert "import path" in diagnostic.recommendation + + +def test_multiple_findings_are_classified(): + findings = [ + InspectionFinding("PASS", "physics/test.py", "Valid."), + InspectionFinding("PENDING", "visualization/test.py", "Pending."), + InspectionFinding("FAIL", "physics/test.py", "Syntax error."), + ] + + diagnostics = analyze_findings(findings) + + assert len(diagnostics) == 3 + assert [item.status for item in diagnostics] == [ + "PASS", + "PENDING", + "FAIL", + ] diff --git a/tests/test_guardian_pipeline.py b/tests/test_guardian_pipeline.py new file mode 100644 index 0000000..07b76b9 --- /dev/null +++ b/tests/test_guardian_pipeline.py @@ -0,0 +1,53 @@ + +"""End-to-end tests for the FiberLab Guardian pipeline.""" + +from guardian.reporter import generate_report + + +def create_module(root, relative_path, source): + """Create a simulated teammate module.""" + file_path = root / relative_path + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_text(source, encoding="utf-8") + + +def test_guardian_detects_multiple_integration_problems(tmp_path): + # Simulate a teammate submitting an incorrect physics module. + create_module( + tmp_path, + "physics/numerical_aperture.py", + """ +def calculate_numerical_aperture(n_core, cladding_index): + return 0.0 + +def calculate_critical_angle(n_core, n_cladding): + return 0.0 +""", + ) + + # Simulate an invalid import and forbidden dependency. + create_module( + tmp_path, + "visualization/signal_graphs.py", + """ +import streamlit +from physics.numerical_aperture import calculate_missing_function +""", + ) + + report = generate_report(tmp_path) + + failed_messages = [ + diagnostic.message.lower() + for diagnostic in report.diagnostics + if diagnostic.status == "FAIL" + ] + + assert report.overall_status == "FAIL" + + assert any("incompatible signature" in message for message in failed_messages) + assert any("missing imported symbol" in message for message in failed_messages) + assert any("streamlit import" in message for message in failed_messages) + + assert report.failed >= 3 + assert report.pending > 0 diff --git a/tests/test_guardian_reporter.py b/tests/test_guardian_reporter.py new file mode 100644 index 0000000..da876ec --- /dev/null +++ b/tests/test_guardian_reporter.py @@ -0,0 +1,36 @@ + +"""Tests for the FiberLab Guardian reporter.""" + +from guardian.reporter import format_report, generate_report + + +def test_report_detects_pending_modules(tmp_path): + report = generate_report(tmp_path) + + assert report.overall_status == "PENDING" + assert report.pending > 0 + + +def test_report_counts_inspection_failures(tmp_path): + report = generate_report(tmp_path) + + assert report.failed == 0 + + +def test_report_contains_all_summary_sections(tmp_path): + report = generate_report(tmp_path) + output = format_report(report) + + assert "FIBERLAB GUARDIAN REPORT" in output + assert "Overall Status:" in output + assert "Passed:" in output + assert "Failed:" in output + assert "Pending:" in output + + +def test_report_contains_diagnostic_details(tmp_path): + report = generate_report(tmp_path) + output = format_report(report) + + assert "DIAGNOSTICS" in output + assert "Recommendation:" in output From f7418561ba6fe3bb5980ea3887c0b58ff274c666 Mon Sep 17 00:00:00 2001 From: CodingBunny06 <210141639+CodingBunny06@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:21:49 +0530 Subject: [PATCH 19/44] feat: add isolated Guardian integration workspace --- guardian/integration_engine.py | 68 +++++++++++++++++++++++ tests/test_guardian_integration_engine.py | 60 ++++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 guardian/integration_engine.py create mode 100644 tests/test_guardian_integration_engine.py diff --git a/guardian/integration_engine.py b/guardian/integration_engine.py new file mode 100644 index 0000000..fc6ce86 --- /dev/null +++ b/guardian/integration_engine.py @@ -0,0 +1,68 @@ + +"""Build isolated workspaces for FiberLab Guardian integration checks.""" + +import shutil +from pathlib import Path + +IGNORED_DIRECTORIES = { + ".git", + ".venv", + "__pycache__", + ".pytest_cache", + ".ruff_cache", + ".mypy_cache", + "htmlcov", +} + +IGNORED_FILES = { + ".coverage", +} + + +def create_integration_workspace( + source_root: Path, + destination: Path, +) -> Path: + """Copy a project into an isolated workspace, excluding development files.""" + source_root = source_root.resolve() + destination = destination.resolve() + + if not source_root.exists(): + raise FileNotFoundError(f"Source directory does not exist: {source_root}") + + if not source_root.is_dir(): + raise NotADirectoryError(f"Source path is not a directory: {source_root}") + + if destination.exists(): + raise FileExistsError( + f"Integration workspace already exists: {destination}" + ) + + if destination.is_relative_to(source_root): + raise ValueError( + "Integration workspace must be outside the source project." + ) + + for path in source_root.rglob("*"): + if path.is_symlink(): + raise ValueError(f"Symlinks are not allowed in the source project: {path}") + + def ignore_development_files( + directory: str, + names: list[str], + ) -> set[str]: + """Exclude environments, caches, and coverage files.""" + return { + name + for name in names + if name in IGNORED_DIRECTORIES or name in IGNORED_FILES + or name.endswith(".pyc") + } + + shutil.copytree( + source_root, + destination, + ignore=ignore_development_files, + ) + + return destination diff --git a/tests/test_guardian_integration_engine.py b/tests/test_guardian_integration_engine.py new file mode 100644 index 0000000..bc144ac --- /dev/null +++ b/tests/test_guardian_integration_engine.py @@ -0,0 +1,60 @@ + +"""Tests for the FiberLab Guardian integration engine.""" + + +import pytest + +from guardian.integration_engine import create_integration_workspace + + +def test_workspace_copies_project_files(tmp_path): + source = tmp_path / "source" + destination = tmp_path / "workspace" + + source.mkdir() + (source / "app.py").write_text("print('FiberLab')", encoding="utf-8") + + result = create_integration_workspace(source, destination) + + assert result == destination + assert (destination / "app.py").read_text(encoding="utf-8") == ( + "print('FiberLab')" + ) + + +def test_workspace_excludes_development_directories(tmp_path): + source = tmp_path / "source" + destination = tmp_path / "workspace" + + source.mkdir() + (source / "app.py").write_text("print('FiberLab')", encoding="utf-8") + + for directory in (".git", ".venv", "__pycache__", ".pytest_cache", ".ruff_cache"): + ignored = source / directory + ignored.mkdir() + (ignored / "internal.txt").write_text("exclude me", encoding="utf-8") + + create_integration_workspace(source, destination) + + for directory in (".git", ".venv", "__pycache__", ".pytest_cache", ".ruff_cache"): + assert not (destination / directory).exists() + + +def test_workspace_refuses_existing_destination(tmp_path): + source = tmp_path / "source" + destination = tmp_path / "workspace" + + source.mkdir() + destination.mkdir() + + with pytest.raises(FileExistsError): + create_integration_workspace(source, destination) + + +def test_workspace_rejects_missing_source(tmp_path): + source = tmp_path / "missing" + destination = tmp_path / "workspace" + + with pytest.raises(FileNotFoundError): + create_integration_workspace(source, destination) + From 33f08b2bd1cd21f091c6042c155a1b96bb714fb0 Mon Sep 17 00:00:00 2001 From: CodingBunny06 <210141639+CodingBunny06@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:43:44 +0530 Subject: [PATCH 20/44] feat: strengthen recursive integration validation --- guardian/inspector.py | 46 +++++++++++++++++++++++++-- tests/test_guardian_inspector.py | 53 ++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/guardian/inspector.py b/guardian/inspector.py index 7359026..349cabd 100644 --- a/guardian/inspector.py +++ b/guardian/inspector.py @@ -137,7 +137,7 @@ def inspect_project(project_root: Path) -> list[InspectionFinding]: if not package_path.exists(): continue - for file_path in package_path.glob("*.py"): + for file_path in package_path.rglob("*.py"): try: module = ast.parse( file_path.read_text(encoding="utf-8"), @@ -173,7 +173,7 @@ def inspect_project(project_root: Path) -> list[InspectionFinding]: if not package_path.exists(): continue - for file_path in package_path.glob("*.py"): + for file_path in package_path.rglob("*.py"): relative_path = file_path.relative_to(project_root).as_posix() try: @@ -202,7 +202,8 @@ def inspect_project(project_root: Path) -> list[InspectionFinding]: ) ) findings.extend(inspect_internal_imports(project_root)) - + findings.extend(inspect_package_syntax(project_root, findings)) + return findings @@ -317,3 +318,42 @@ def inspect_internal_imports(project_root: Path) -> list[InspectionFinding]: ) return findings + +def inspect_package_syntax( + project_root: Path, + existing_findings: list[InspectionFinding], +) -> list[InspectionFinding]: + """Detect syntax errors in all physics and visualization Python files.""" + findings = [] + + already_reported = { + finding.file + for finding in existing_findings + if finding.status == "FAIL" + and "Unable to parse module" in finding.message + } + + for package in ("physics", "visualization"): + package_path = project_root / package + + if not package_path.exists(): + continue + + for file_path in package_path.rglob("*.py"): + relative_path = file_path.relative_to(project_root).as_posix() + + if relative_path in already_reported: + continue + + try: + source = file_path.read_text(encoding="utf-8") + ast.parse(source, filename=str(file_path)) + except (SyntaxError, UnicodeDecodeError) as error: + findings.append( + InspectionFinding( + "FAIL", + relative_path, + f"Unable to parse module: {error}", + ) + ) + return findings diff --git a/tests/test_guardian_inspector.py b/tests/test_guardian_inspector.py index 34eb395..35e7961 100644 --- a/tests/test_guardian_inspector.py +++ b/tests/test_guardian_inspector.py @@ -191,3 +191,56 @@ def calculate_output_power(input_power_mw, attenuation_db_per_km, length_km): and "calculate_loss" in finding.message for finding in findings ) + +def test_syntax_error_in_non_contract_module_is_detected(tmp_path): + create_module( + tmp_path, + "visualization/helpers.py", + "def create_figure(:", + ) + + findings = inspect_project(tmp_path) + + assert any( + finding.status == "FAIL" + and finding.file == "visualization/helpers.py" + and "Unable to parse module" in finding.message + for finding in findings + ) + + +def test_streamlit_import_in_nested_module_is_detected(tmp_path): + create_module( + tmp_path, + "visualization/helpers/renderer.py", + "import streamlit", + ) + + findings = inspect_project(tmp_path) + + assert any( + finding.status == "FAIL" + and finding.file == "visualization/helpers/renderer.py" + and "Streamlit import" in finding.message + for finding in findings + ) + + +def test_contract_function_in_nested_module_is_detected(tmp_path): + create_module( + tmp_path, + "physics/helpers/numerical.py", + """ +def calculate_numerical_aperture(n_core, n_cladding): + return 0.0 +""", + ) + + findings = inspect_project(tmp_path) + + assert any( + finding.status == "FAIL" + and finding.file == "physics/helpers/numerical.py" + and "belongs in physics/numerical_aperture.py" in finding.message + for finding in findings + ) From ed04d4aea8a180ef677e39f2e7d30267da3cb4a2 Mon Sep 17 00:00:00 2001 From: CodingBunny06 <210141639+CodingBunny06@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:48:27 +0530 Subject: [PATCH 21/44] feat: add Guardian automated test runner --- guardian/test_runner.py | 67 ++++++++++++++++++++++++ tests/test_guardian_test_runner.py | 82 ++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 guardian/test_runner.py create mode 100644 tests/test_guardian_test_runner.py diff --git a/guardian/test_runner.py b/guardian/test_runner.py new file mode 100644 index 0000000..04cdf61 --- /dev/null +++ b/guardian/test_runner.py @@ -0,0 +1,67 @@ + +"""Run automated quality checks for FiberLab Guardian.""" + +import subprocess +from dataclasses import dataclass +from pathlib import Path + + +@dataclass +class CommandResult: + """Store the result of an executed command.""" + + status: str + return_code: int | None + stdout: str + stderr: str + + +def run_command( + command: list[str], + workspace_root: Path, + timeout_seconds: int = 120, +) -> CommandResult: + """Execute a command inside the isolated integration workspace.""" + try: + completed = subprocess.run( + command, + cwd=workspace_root, + capture_output=True, + text=True, + timeout=timeout_seconds, + check=False, + ) + + status = "PASS" if completed.returncode == 0 else "FAIL" + + return CommandResult( + status=status, + return_code=completed.returncode, + stdout=completed.stdout, + stderr=completed.stderr, + ) + + except subprocess.TimeoutExpired as error: + stdout = error.stdout or "" + stderr = error.stderr or "" + + if isinstance(stdout, bytes): + stdout = stdout.decode(errors="replace") + + if isinstance(stderr, bytes): + stderr = stderr.decode(errors="replace") + + return CommandResult( + status="TIMEOUT", + return_code=None, + stdout=stdout, + stderr=stderr or "Command exceeded its execution timeout.", + ) + + except OSError as error: + return CommandResult( + status="ERROR", + return_code=None, + stdout="", + stderr=str(error), + ) diff --git a/tests/test_guardian_test_runner.py b/tests/test_guardian_test_runner.py new file mode 100644 index 0000000..1a347a3 --- /dev/null +++ b/tests/test_guardian_test_runner.py @@ -0,0 +1,82 @@ + +"""Tests for the FiberLab Guardian automated test runner.""" + +import subprocess + +from guardian.test_runner import run_command + + +def test_successful_command_returns_pass(monkeypatch, tmp_path): + def fake_run(*args, **kwargs): + return subprocess.CompletedProcess( + args[0], 0, stdout="All checks passed", stderr="" + ) + + monkeypatch.setattr(subprocess, "run", fake_run) + + result = run_command(["ruff", "check", "."], tmp_path) + + assert result.status == "PASS" + assert result.return_code == 0 + assert result.stdout == "All checks passed" + assert result.stderr == "" + + +def test_failed_command_returns_fail(monkeypatch, tmp_path): + def fake_run(*args, **kwargs): + return subprocess.CompletedProcess( + args[0], 1, stdout="", stderr="Lint errors found" + ) + + monkeypatch.setattr(subprocess, "run", fake_run) + + result = run_command(["ruff", "check", "."], tmp_path) + + assert result.status == "FAIL" + assert result.return_code == 1 + assert result.stderr == "Lint errors found" + + +def test_timed_out_command_returns_timeout(monkeypatch, tmp_path): + def fake_run(*args, **kwargs): + raise subprocess.TimeoutExpired(args[0], kwargs["timeout"]) + + monkeypatch.setattr(subprocess, "run", fake_run) + + result = run_command( + ["python", "-m", "pytest"], + tmp_path, + timeout_seconds=5, + ) + + assert result.status == "TIMEOUT" + assert result.return_code is None + + +def test_execution_error_is_captured(monkeypatch, tmp_path): + def fake_run(*args, **kwargs): + raise OSError("Command could not start") + + monkeypatch.setattr(subprocess, "run", fake_run) + + result = run_command(["missing-command"], tmp_path) + + assert result.status == "ERROR" + assert result.return_code is None + assert "could not start" in result.stderr.lower() + + +def test_command_runs_in_requested_workspace(monkeypatch, tmp_path): + captured = {} + + def fake_run(*args, **kwargs): + captured["cwd"] = kwargs["cwd"] + return subprocess.CompletedProcess( + args[0], 0, stdout="", stderr="" + ) + + monkeypatch.setattr(subprocess, "run", fake_run) + + run_command(["ruff", "check", "."], tmp_path) + + assert captured["cwd"] == tmp_path From 1ee85fc914ade10024bd14cb88699a776a8cb5db Mon Sep 17 00:00:00 2001 From: CodingBunny06 <210141639+CodingBunny06@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:57:54 +0530 Subject: [PATCH 22/44] feat: add candidate module integration --- guardian/integration_engine.py | 48 +++++++++++++ tests/test_guardian_integration_engine.py | 82 ++++++++++++++++++++++- 2 files changed, 129 insertions(+), 1 deletion(-) diff --git a/guardian/integration_engine.py b/guardian/integration_engine.py index fc6ce86..a33dc5e 100644 --- a/guardian/integration_engine.py +++ b/guardian/integration_engine.py @@ -66,3 +66,51 @@ def ignore_development_files( ) return destination + +def integrate_candidate_modules( + candidate_root: Path, + workspace_root: Path, + selected_files: list[str], +) -> list[Path]: + """Copy selected candidate modules into an isolated integration workspace.""" + candidate_root = candidate_root.resolve() + workspace_root = workspace_root.resolve() + + if not candidate_root.is_dir(): + raise FileNotFoundError( + f"Candidate directory does not exist: {candidate_root}" + ) + + if not workspace_root.is_dir(): + raise FileNotFoundError( + f"Integration workspace does not exist: {workspace_root}" + ) + + integrated_files = [] + + for relative_path in selected_files: + relative_path = Path(relative_path) + + if relative_path.is_absolute() or ".." in relative_path.parts: + raise ValueError(f"Unsafe candidate path: {relative_path}") + + source_file = (candidate_root / relative_path).resolve() + + if not source_file.is_relative_to(candidate_root): + raise ValueError(f"Unsafe candidate path: {relative_path}") + + if not source_file.is_file(): + raise FileNotFoundError( + f"Candidate file does not exist: {source_file}" + ) + + if source_file.is_symlink(): + raise ValueError(f"Symlinks are not allowed: {relative_path}") + + destination_file = workspace_root / relative_path + destination_file.parent.mkdir(parents=True, exist_ok=True) + + shutil.copy2(source_file, destination_file) + integrated_files.append(destination_file) + + return integrated_files diff --git a/tests/test_guardian_integration_engine.py b/tests/test_guardian_integration_engine.py index bc144ac..0ba02da 100644 --- a/tests/test_guardian_integration_engine.py +++ b/tests/test_guardian_integration_engine.py @@ -4,7 +4,10 @@ import pytest -from guardian.integration_engine import create_integration_workspace +from guardian.integration_engine import ( + create_integration_workspace, + integrate_candidate_modules, +) def test_workspace_copies_project_files(tmp_path): @@ -58,3 +61,80 @@ def test_workspace_rejects_missing_source(tmp_path): with pytest.raises(FileNotFoundError): create_integration_workspace(source, destination) + +def test_integrates_selected_candidate_module(tmp_path): + candidate = tmp_path / "candidate" + workspace = tmp_path / "workspace" + + (candidate / "physics").mkdir(parents=True) + (workspace / "physics").mkdir(parents=True) + + source_file = candidate / "physics" / "attenuation.py" + source_file.write_text("def calculate_loss():\n return 5\n", encoding="utf-8") + + result = integrate_candidate_modules( + candidate, + workspace, + ["physics/attenuation.py"], + ) + + destination_file = workspace / "physics" / "attenuation.py" + + assert destination_file.exists() + assert destination_file.read_text(encoding="utf-8") == source_file.read_text( + encoding="utf-8" + ) + assert result == [destination_file] + + +def test_integrates_only_selected_files(tmp_path): + candidate = tmp_path / "candidate" + workspace = tmp_path / "workspace" + + candidate.mkdir() + workspace.mkdir() + + (candidate / "selected.py").write_text("selected = True", encoding="utf-8") + (candidate / "unselected.py").write_text("unselected = True", encoding="utf-8") + + integrate_candidate_modules(candidate, workspace, ["selected.py"]) + + assert (workspace / "selected.py").exists() + assert not (workspace / "unselected.py").exists() + + +def test_integrates_candidate_into_existing_workspace_file(tmp_path): + candidate = tmp_path / "candidate" + workspace = tmp_path / "workspace" + + candidate.mkdir() + workspace.mkdir() + + (candidate / "app.py").write_text("candidate version", encoding="utf-8") + (workspace / "app.py").write_text("placeholder version", encoding="utf-8") + + integrate_candidate_modules(candidate, workspace, ["app.py"]) + + assert (workspace / "app.py").read_text(encoding="utf-8") == "candidate version" + + +def test_rejects_missing_candidate_file(tmp_path): + candidate = tmp_path / "candidate" + workspace = tmp_path / "workspace" + + candidate.mkdir() + workspace.mkdir() + + with pytest.raises(FileNotFoundError): + integrate_candidate_modules(candidate, workspace, ["physics/missing.py"]) + + +def test_rejects_unsafe_candidate_path(tmp_path): + candidate = tmp_path / "candidate" + workspace = tmp_path / "workspace" + + candidate.mkdir() + workspace.mkdir() + + with pytest.raises(ValueError): + integrate_candidate_modules(candidate, workspace, ["../outside.py"]) From f5b43eb9d39d51ee83dc448a583eae82d293dec8 Mon Sep 17 00:00:00 2001 From: CodingBunny06 <210141639+CodingBunny06@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:07:12 +0530 Subject: [PATCH 23/44] feat: add Guardian execution pipeline --- guardian/runner.py | 95 ++++++++++++++++++++++++++++++ tests/test_guardian_runner.py | 108 ++++++++++++++++++++++++++++++++++ 2 files changed, 203 insertions(+) create mode 100644 guardian/runner.py create mode 100644 tests/test_guardian_runner.py diff --git a/guardian/runner.py b/guardian/runner.py new file mode 100644 index 0000000..be62ba6 --- /dev/null +++ b/guardian/runner.py @@ -0,0 +1,95 @@ + +"""Coordinate the FiberLab Guardian integration workflow.""" + +from pathlib import Path + +from guardian.error_detector import Diagnostic, analyze_findings +from guardian.inspector import inspect_project +from guardian.integration_engine import ( + create_integration_workspace, + integrate_candidate_modules, +) +from guardian.reporter import ( + GuardianReport, +) +from guardian.test_runner import CommandResult, run_command + + +def run_guardian( + project_root: Path, + candidate_root: Path, + selected_files: list[str], + workspace_root: Path, + run_quality_checks: bool = True, + timeout_seconds: int = 120, +) -> GuardianReport: + """Integrate candidate modules, inspect the workspace and run quality checks.""" + + create_integration_workspace(project_root, workspace_root) + + integrate_candidate_modules( + candidate_root, + workspace_root, + selected_files, + ) + + findings = inspect_project(workspace_root) + diagnostics = analyze_findings(findings) + + passed = sum(item.status == "PASS" for item in diagnostics) + failed = sum(item.status == "FAIL" for item in diagnostics) + pending = sum(item.status == "PENDING" for item in diagnostics) + + if failed: + return GuardianReport( + diagnostics=diagnostics, + passed=passed, + failed=failed, + pending=pending, + ) + + if run_quality_checks: + quality_results = [ + run_command( + ["ruff", "check", "."], + workspace_root, + timeout_seconds, + ), + run_command( + ["python", "-m", "pytest", "-v"], + workspace_root, + timeout_seconds, + ), + ] + + for result in quality_results: + if result.status != "PASS": + failed += 1 + + diagnostics.append(_command_diagnostic(result)) + + return GuardianReport( + diagnostics=diagnostics, + passed=passed, + failed=failed, + pending=pending, + ) + + +def _command_diagnostic(result: CommandResult): + """Convert an automated command result into a Guardian diagnostic.""" + + return Diagnostic( + status="PASS" if result.status == "PASS" else "FAIL", + severity="INFO" if result.status == "PASS" else "HIGH", + file="integration workspace", + message=( + f"Automated check: {result.status} " + f"(exit code: {result.return_code})" + ), + recommendation=( + "No action required." + if result.status == "PASS" + else result.stderr or result.stdout or "Review the command failure." + ), + ) diff --git a/tests/test_guardian_runner.py b/tests/test_guardian_runner.py new file mode 100644 index 0000000..06b9b63 --- /dev/null +++ b/tests/test_guardian_runner.py @@ -0,0 +1,108 @@ + +"""Tests for the FiberLab Guardian execution pipeline.""" + + +from guardian.runner import run_guardian + + +def test_guardian_creates_workspace_and_generates_report(tmp_path): + project = tmp_path / "project" + candidate = tmp_path / "candidate" + workspace = tmp_path / "workspace" + + project.mkdir() + candidate.mkdir() + + (project / "app.py").write_text("print('FiberLab')", encoding="utf-8") + (candidate / "app.py").write_text( + "print('Candidate FiberLab')", + encoding="utf-8", + ) + + report = run_guardian( + project_root=project, + candidate_root=candidate, + selected_files=["app.py"], + workspace_root=workspace, + run_quality_checks=False, + ) + + assert workspace.exists() + assert (workspace / "app.py").read_text(encoding="utf-8") == ( + "print('Candidate FiberLab')" + ) + assert report is not None + assert report.overall_status in ("PASS", "PENDING", "FAIL") + + +def test_guardian_does_not_modify_original_project(tmp_path): + project = tmp_path / "project" + candidate = tmp_path / "candidate" + workspace = tmp_path / "workspace" + + project.mkdir() + candidate.mkdir() + + original_content = "print('Original FiberLab')" + (project / "app.py").write_text(original_content, encoding="utf-8") + (candidate / "app.py").write_text( + "print('Candidate version')", + encoding="utf-8", + ) + + run_guardian( + project_root=project, + candidate_root=candidate, + selected_files=["app.py"], + workspace_root=workspace, + run_quality_checks=False, + ) + + assert (project / "app.py").read_text(encoding="utf-8") == original_content + + +def test_guardian_rejects_missing_candidate_file(tmp_path): + project = tmp_path / "project" + candidate = tmp_path / "candidate" + workspace = tmp_path / "workspace" + + project.mkdir() + candidate.mkdir() + + (project / "app.py").write_text("print('FiberLab')", encoding="utf-8") + + try: + run_guardian( + project_root=project, + candidate_root=candidate, + selected_files=["physics/missing.py"], + workspace_root=workspace, + run_quality_checks=False, + ) + except FileNotFoundError: + pass + else: + raise AssertionError("Expected FileNotFoundError") + + +def test_guardian_rejects_existing_workspace(tmp_path): + project = tmp_path / "project" + candidate = tmp_path / "candidate" + workspace = tmp_path / "workspace" + + project.mkdir() + candidate.mkdir() + workspace.mkdir() + + try: + run_guardian( + project_root=project, + candidate_root=candidate, + selected_files=[], + workspace_root=workspace, + run_quality_checks=False, + ) + except FileExistsError: + pass + else: + raise AssertionError("Expected FileExistsError") From 98dee31d8ab767f6246f7ed59b3e0fdfd5cbe9de Mon Sep 17 00:00:00 2001 From: CodingBunny06 <210141639+CodingBunny06@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:13:22 +0530 Subject: [PATCH 24/44] test: add Guardian end-to-end coverage --- guardian/runner.py | 32 ++++---- tests/test_guardian_end_to_end.py | 122 ++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 15 deletions(-) create mode 100644 tests/test_guardian_end_to_end.py diff --git a/guardian/runner.py b/guardian/runner.py index be62ba6..99f7e10 100644 --- a/guardian/runner.py +++ b/guardian/runner.py @@ -48,25 +48,28 @@ def run_guardian( pending=pending, ) + + if run_quality_checks: - quality_results = [ - run_command( - ["ruff", "check", "."], - workspace_root, - timeout_seconds, - ), - run_command( - ["python", "-m", "pytest", "-v"], + commands = [ + ["ruff", "check", "."], + ["python", "-m", "pytest", "-v"], + ] + + for command in commands: + result = run_command( + command, workspace_root, timeout_seconds, - ), - ] + ) - for result in quality_results: - if result.status != "PASS": - failed += 1 + diagnostic = _command_diagnostic(result) + diagnostics.append(diagnostic) - diagnostics.append(_command_diagnostic(result)) + if result.status == "PASS": + passed += 1 + else: + failed += 1 return GuardianReport( diagnostics=diagnostics, @@ -75,7 +78,6 @@ def run_guardian( pending=pending, ) - def _command_diagnostic(result: CommandResult): """Convert an automated command result into a Guardian diagnostic.""" diff --git a/tests/test_guardian_end_to_end.py b/tests/test_guardian_end_to_end.py new file mode 100644 index 0000000..b0372ad --- /dev/null +++ b/tests/test_guardian_end_to_end.py @@ -0,0 +1,122 @@ + +"""End-to-end tests for the FiberLab Guardian pipeline.""" + + +from guardian.runner import run_guardian +from guardian.test_runner import CommandResult + + +def test_guardian_completes_full_workflow(tmp_path, monkeypatch): + """Verify workspace creation, integration, inspection and quality checks.""" + + project = tmp_path / "project" + candidate = tmp_path / "candidate" + workspace = tmp_path / "workspace" + + project.mkdir() + candidate.mkdir() + + (project / "app.py").write_text("print('original')\n") + (candidate / "app.py").write_text("print('candidate')\n") + + calls = [] + + def mock_run_command(command, workspace_root, timeout_seconds): + calls.append(command) + return CommandResult("PASS", 0, "Success", "") + + monkeypatch.setattr("guardian.runner.run_command", mock_run_command) + + report = run_guardian( + project, + candidate, + ["app.py"], + workspace, + ) + + assert workspace.exists() + assert (workspace / "app.py").read_text() == "print('candidate')\n" + assert (project / "app.py").read_text() == "print('original')\n" + + assert len(calls) == 2 + assert report.failed == 0 + assert report.passed >= 2 + + +def test_guardian_stops_after_structural_failure(tmp_path, monkeypatch): + """Verify invalid candidate syntax prevents quality-check execution.""" + + project = tmp_path / "project" + candidate = tmp_path / "candidate" + workspace = tmp_path / "workspace" + + project.mkdir() + candidate.mkdir() + + (project / "app.py").write_text("print('original')\n") + + physics_dir = candidate / "physics" + physics_dir.mkdir() + + (physics_dir / "numerical_aperture.py").write_text( + "def broken_function(\n" + ) + + def unexpected_command(*args, **kwargs): + raise AssertionError("Quality checks should not execute.") + + monkeypatch.setattr( + "guardian.runner.run_command", + unexpected_command, + ) + + report = run_guardian( + project, + candidate, + ["physics/numerical_aperture.py"], + workspace, + ) + + assert report.failed >= 1 + assert report.overall_status == "FAIL" + + +def test_guardian_reports_quality_check_failure(tmp_path, monkeypatch): + """Verify failed automated checks appear in the final report.""" + + project = tmp_path / "project" + candidate = tmp_path / "candidate" + workspace = tmp_path / "workspace" + + project.mkdir() + candidate.mkdir() + + (project / "app.py").write_text("print('original')\n") + (candidate / "app.py").write_text("print('candidate')\n") + + calls = [] + + def mock_run_command(command, workspace_root, timeout_seconds): + calls.append(command) + + if command[0] == "ruff": + return CommandResult("FAIL", 1, "", "Ruff detected an issue.") + + return CommandResult("PASS", 0, "Tests passed", "") + + monkeypatch.setattr("guardian.runner.run_command", mock_run_command) + + report = run_guardian( + project, + candidate, + ["app.py"], + workspace, + ) + + assert len(calls) == 2 + assert report.failed == 1 + assert report.overall_status == "FAIL" + assert any( + "Ruff detected an issue." in item.recommendation + for item in report.diagnostics + ) From ad85e20119cf9a300d45c6fe38486c6df53d9560 Mon Sep 17 00:00:00 2001 From: CodingBunny06 <210141639+CodingBunny06@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:17:02 +0530 Subject: [PATCH 25/44] feat: improve Guardian diagnostic reports --- guardian/reporter.py | 47 +++++++++++++------- tests/test_guardian_report_quality.py | 63 +++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 15 deletions(-) create mode 100644 tests/test_guardian_report_quality.py diff --git a/guardian/reporter.py b/guardian/reporter.py index 8556982..1d3412a 100644 --- a/guardian/reporter.py +++ b/guardian/reporter.py @@ -42,29 +42,46 @@ def generate_report(project_root: Path) -> GuardianReport: ) + def format_report(report: GuardianReport) -> str: - """Format Guardian results into a readable text report.""" + """Format a Guardian report into a readable diagnostic summary.""" + lines = [ "FIBERLAB GUARDIAN REPORT", - "=" * 30, + "=" * 40, + "", f"Overall Status: {report.overall_status}", - f"Passed: {report.passed}", - f"Failed: {report.failed}", + "", + "SUMMARY", + "-" * 40, + f"Passed: {report.passed}", + f"Failed: {report.failed}", f"Pending: {report.pending}", "", "DIAGNOSTICS", - "-" * 30, + "-" * 40, ] - for diagnostic in report.diagnostics: - lines.extend( - [ - f"[{diagnostic.status}] {diagnostic.file}", - f"Severity: {diagnostic.severity}", - f"Finding: {diagnostic.message}", - f"Recommendation: {diagnostic.recommendation}", - "", - ] - ) + if not report.diagnostics: + lines.append("No diagnostics available.") + else: + for index, diagnostic in enumerate(report.diagnostics, start=1): + lines.extend( + [ + "", + f"{index}. [{diagnostic.status}] {diagnostic.file}", + f" Severity: {diagnostic.severity}", + f" Finding: {diagnostic.message}", + f" Recommendation: {diagnostic.recommendation}", + ] + ) + + lines.extend( + [ + "", + "=" * 40, + "End of Guardian Report", + ] + ) return "\n".join(lines) diff --git a/tests/test_guardian_report_quality.py b/tests/test_guardian_report_quality.py new file mode 100644 index 0000000..9ca680c --- /dev/null +++ b/tests/test_guardian_report_quality.py @@ -0,0 +1,63 @@ + +"""Tests for Guardian report quality and diagnostics.""" + +from guardian.error_detector import Diagnostic +from guardian.reporter import GuardianReport, format_report + + +def test_report_prioritizes_failure_over_pending(): + report = GuardianReport( + diagnostics=[], + passed=2, + failed=1, + pending=3, + ) + + assert report.overall_status == "FAIL" + + +def test_report_returns_pending_when_no_failures(): + report = GuardianReport( + diagnostics=[], + passed=2, + failed=0, + pending=3, + ) + + assert report.overall_status == "PENDING" + + +def test_report_returns_pass_when_no_failures_or_pending(): + report = GuardianReport( + diagnostics=[], + passed=5, + failed=0, + pending=0, + ) + + assert report.overall_status == "PASS" + + +def test_formatted_report_contains_diagnostic_details(): + diagnostic = Diagnostic( + status="FAIL", + severity="HIGH", + file="physics/numerical_aperture.py", + message="Function signature does not match the contract.", + recommendation="Update the function signature.", + ) + + report = GuardianReport( + diagnostics=[diagnostic], + passed=2, + failed=1, + pending=0, + ) + + output = format_report(report) + + assert "FAIL" in output + assert "physics/numerical_aperture.py" in output + assert "HIGH" in output + assert "Function signature does not match the contract." in output + assert "Update the function signature." in output From 0c93230c388417bcdaaa560f3b9a14359147958a Mon Sep 17 00:00:00 2001 From: CodingBunny06 <210141639+CodingBunny06@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:44:42 +0530 Subject: [PATCH 26/44] feat: add GitHub API client for Guardian --- guardian/github_client.py | 186 ++++++++++++++++++++++++++ tests/test_guardian_github_client.py | 191 +++++++++++++++++++++++++++ 2 files changed, 377 insertions(+) create mode 100644 guardian/github_client.py create mode 100644 tests/test_guardian_github_client.py diff --git a/guardian/github_client.py b/guardian/github_client.py new file mode 100644 index 0000000..9584812 --- /dev/null +++ b/guardian/github_client.py @@ -0,0 +1,186 @@ + +"""Read-only GitHub API client for FiberLab Guardian.""" + +import json +import os +import re +from dataclasses import dataclass +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + + +@dataclass +class PullRequestInfo: + """Metadata describing a GitHub pull request.""" + + number: int + title: str + state: str + base_branch: str + head_branch: str + head_sha: str + head_repo: str | None + + +@dataclass +class ChangedFile: + """File changed by a GitHub pull request.""" + + path: str + status: str + additions: int + deletions: int + changes: int + + +class GitHubAPIError(Exception): + """Raised when a GitHub API request fails.""" + + +class GitHubClient: + """Read-only client for retrieving GitHub pull request data.""" + + API_URL = "https://api.github.com" + API_VERSION = "2022-11-28" + + def __init__(self, token: str | None = None, timeout: int = 15): + self.token = token or os.getenv("GITHUB_TOKEN") + self.timeout = timeout + + @staticmethod + def _validate_repository(repository: str) -> None: + if not isinstance(repository, str) or not re.fullmatch( + r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", + repository, + ): + raise ValueError("Repository must use the owner/repository format.") + + @staticmethod + def _validate_pr_number(pr_number: int) -> None: + if ( + isinstance(pr_number, bool) + or not isinstance(pr_number, int) + or pr_number <= 0 + ): + raise ValueError("Pull request number must be a positive integer.") + + def _request(self, endpoint: str) -> dict | list: + headers = { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": self.API_VERSION, + "User-Agent": "FiberLab-Guardian", + } + + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + + request = Request( + f"{self.API_URL}{endpoint}", + headers=headers, + method="GET", + ) + + try: + with urlopen(request, timeout=self.timeout) as response: + return json.loads(response.read().decode("utf-8")) + + except HTTPError as error: + if error.code == 404: + raise GitHubAPIError("GitHub resource was not found.") from error + + if error.code == 401: + raise GitHubAPIError("GitHub authentication failed.") from error + + if error.code == 403: + raise GitHubAPIError( + "GitHub access was denied or rate-limited." + ) from error + + raise GitHubAPIError( + f"GitHub API returned HTTP {error.code}." + ) from error + + except (URLError, TimeoutError, OSError) as error: + raise GitHubAPIError( + "Unable to connect to the GitHub API." + ) from error + + except (json.JSONDecodeError, UnicodeDecodeError) as error: + raise GitHubAPIError( + "GitHub returned an invalid JSON response." + ) from error + + def get_pull_request( + self, + repository: str, + pr_number: int, + ) -> PullRequestInfo: + """Retrieve pull request metadata.""" + + self._validate_repository(repository) + self._validate_pr_number(pr_number) + + data = self._request( + f"/repos/{repository}/pulls/{pr_number}" + ) + + if not isinstance(data, dict): + raise GitHubAPIError("Unexpected pull request response.") + + head = data.get("head") or {} + base = data.get("base") or {} + head_repo = head.get("repo") + + return PullRequestInfo( + number=data["number"], + title=data["title"], + state=data["state"], + base_branch=base["ref"], + head_branch=head["ref"], + head_sha=head["sha"], + head_repo=( + head_repo.get("full_name") + if isinstance(head_repo, dict) + else None + ), + ) + + def list_changed_files( + self, + repository: str, + pr_number: int, + ) -> list[ChangedFile]: + """Retrieve every file changed by a pull request.""" + + self._validate_repository(repository) + self._validate_pr_number(pr_number) + + files = [] + page = 1 + + while True: + data = self._request( + f"/repos/{repository}/pulls/{pr_number}/files" + f"?per_page=100&page={page}" + ) + + if not isinstance(data, list): + raise GitHubAPIError("Unexpected changed-files response.") + + for item in data: + files.append( + ChangedFile( + path=item["filename"], + status=item["status"], + additions=item["additions"], + deletions=item["deletions"], + changes=item["changes"], + ) + ) + + if len(data) < 100: + break + + page += 1 + + return files diff --git a/tests/test_guardian_github_client.py b/tests/test_guardian_github_client.py new file mode 100644 index 0000000..69373e2 --- /dev/null +++ b/tests/test_guardian_github_client.py @@ -0,0 +1,191 @@ +import io +import json +from urllib.error import HTTPError, URLError + +import pytest + +from guardian.github_client import ( + ChangedFile, + GitHubAPIError, + GitHubClient, + PullRequestInfo, +) + + +def test_pull_request_info_stores_metadata(): + pull_request = PullRequestInfo( + number=12, + title="Implement attenuation module", + state="open", + base_branch="main", + head_branch="feature/attenuation", + head_sha="abc123", + head_repo="CodingBunny06/FiberLab", + ) + + assert pull_request.number == 12 + assert pull_request.title == "Implement attenuation module" + assert pull_request.state == "open" + assert pull_request.base_branch == "main" + assert pull_request.head_branch == "feature/attenuation" + assert pull_request.head_sha == "abc123" + + +def test_changed_file_stores_change_details(): + changed_file = ChangedFile( + path="physics/attenuation.py", + status="added", + additions=25, + deletions=0, + changes=25, + ) + + assert changed_file.path == "physics/attenuation.py" + assert changed_file.status == "added" + assert changed_file.additions == 25 + assert changed_file.deletions == 0 + assert changed_file.changes == 25 + + +def test_github_client_rejects_invalid_repository(): + client = GitHubClient() + + with pytest.raises(ValueError): + client.get_pull_request("invalid-repository", 12) + + +def test_github_client_rejects_invalid_pr_number(): + client = GitHubClient() + + with pytest.raises(ValueError): + client.get_pull_request( + "CodingBunny06/FiberLab", + 0, + ) + +def test_github_client_rejects_boolean_pr_number(): + client = GitHubClient() + + with pytest.raises(ValueError): + client.get_pull_request( + "CodingBunny06/FiberLab", + True, + ) + + +def test_github_client_rejects_malformed_repository(): + client = GitHubClient() + + with pytest.raises(ValueError): + client.get_pull_request( + "CodingBunny06/FiberLab/extra", + 12, + ) + +def test_get_pull_request_retrieves_metadata_and_uses_token(monkeypatch): + """Verify PR metadata retrieval and authentication.""" + + response_data = { + "number": 12, + "title": "Implement attenuation module", + "state": "open", + "base": {"ref": "main"}, + "head": { + "ref": "feature/attenuation", + "sha": "abc123", + "repo": {"full_name": "CodingBunny06/FiberLab"}, + }, + } + + def mock_urlopen(request, timeout): + assert request.get_header("Authorization") == "Bearer test-token" + assert timeout == 15 + return io.BytesIO(json.dumps(response_data).encode("utf-8")) + + monkeypatch.setattr("guardian.github_client.urlopen", mock_urlopen) + + client = GitHubClient(token="test-token") + result = client.get_pull_request("CodingBunny06/FiberLab", 12) + + assert result.number == 12 + assert result.title == "Implement attenuation module" + assert result.head_sha == "abc123" + assert result.head_branch == "feature/attenuation" + + +def test_list_changed_files_handles_pagination(monkeypatch): + """Verify changed files are retrieved across multiple API pages.""" + + first_page = [ + { + "filename": f"physics/module_{index}.py", + "status": "added", + "additions": 10, + "deletions": 0, + "changes": 10, + } + for index in range(100) + ] + + second_page = [ + { + "filename": "physics/attenuation.py", + "status": "modified", + "additions": 5, + "deletions": 2, + "changes": 7, + } + ] + + responses = [first_page, second_page] + requested_urls = [] + + def mock_urlopen(request, timeout): + requested_urls.append(request.full_url) + return io.BytesIO( + json.dumps(responses.pop(0)).encode("utf-8") + ) + + monkeypatch.setattr("guardian.github_client.urlopen", mock_urlopen) + + client = GitHubClient() + files = client.list_changed_files("CodingBunny06/FiberLab", 12) + + assert len(files) == 101 + assert files[-1].path == "physics/attenuation.py" + assert "page=1" in requested_urls[0] + assert "page=2" in requested_urls[1] + + +def test_github_client_handles_http_errors(monkeypatch): + """Verify GitHub HTTP errors are converted into API errors.""" + + def mock_urlopen(request, timeout): + raise HTTPError( + request.full_url, + 404, + "Not Found", + None, + None, + ) + + monkeypatch.setattr("guardian.github_client.urlopen", mock_urlopen) + + client = GitHubClient() + + with pytest.raises(GitHubAPIError, match="not found"): + client.get_pull_request("CodingBunny06/FiberLab", 12) + + +def test_github_client_handles_network_errors(monkeypatch): + """Verify network failures are handled cleanly.""" + + def mock_urlopen(request, timeout): + raise URLError("Connection unavailable") + + monkeypatch.setattr("guardian.github_client.urlopen", mock_urlopen) + + client = GitHubClient() + + with pytest.raises(GitHubAPIError, match="Unable to connect"): + client.get_pull_request("CodingBunny06/FiberLab", 12) From baa0b5f1a191664a21e68036192a80543be889ff Mon Sep 17 00:00:00 2001 From: CodingBunny06 <210141639+CodingBunny06@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:47:23 +0530 Subject: [PATCH 27/44] feat: add GitHub candidate downloader --- guardian/candidate_downloader.py | 135 +++++++++++++++++ tests/test_guardian_candidate_downloader.py | 156 ++++++++++++++++++++ 2 files changed, 291 insertions(+) create mode 100644 guardian/candidate_downloader.py create mode 100644 tests/test_guardian_candidate_downloader.py diff --git a/guardian/candidate_downloader.py b/guardian/candidate_downloader.py new file mode 100644 index 0000000..950fdda --- /dev/null +++ b/guardian/candidate_downloader.py @@ -0,0 +1,135 @@ + +"""Download selected GitHub pull request files into an isolated workspace.""" + +import os +import re +from pathlib import Path, PurePosixPath +from urllib.error import HTTPError, URLError +from urllib.parse import quote +from urllib.request import Request, urlopen + + +class CandidateDownloadError(Exception): + """Raised when a candidate file cannot be downloaded.""" + + +def _validate_repository(repository: str) -> None: + if not isinstance(repository, str) or not re.fullmatch( + r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", + repository, + ): + raise ValueError("Repository must use the owner/repository format.") + + +def _validate_commit_sha(commit_sha: str) -> None: + if not isinstance(commit_sha, str) or not re.fullmatch( + r"[0-9a-fA-F]{40}", + commit_sha, + ): + raise ValueError("Commit SHA must be a valid 40-character SHA-1.") + + +def _validate_file_path(file_path: str) -> PurePosixPath: + if not isinstance(file_path, str) or not file_path: + raise ValueError("Candidate file path cannot be empty.") + + if "\\" in file_path: + raise ValueError("Backslashes are not allowed in candidate paths.") + + path = PurePosixPath(file_path) + + if path.is_absolute() or ".." in path.parts or "." in path.parts: + raise ValueError("Candidate file path must stay inside the workspace.") + + return path + + +def download_candidate_files( + repository: str, + commit_sha: str, + selected_files: list[str], + destination: Path, + token: str | None = None, + timeout: int = 15, +) -> list[Path]: + """Download selected files from an exact GitHub commit. + + The destination must not already exist. Downloaded files are stored + without executing them or modifying the source repository. + """ + + _validate_repository(repository) + _validate_commit_sha(commit_sha) + + if not isinstance(selected_files, list) or not selected_files: + raise ValueError("Select at least one candidate file.") + + if isinstance(timeout, bool) or not isinstance(timeout, int) or timeout <= 0: + raise ValueError("Timeout must be a positive integer.") + + validated_paths = [ + _validate_file_path(file_path) + for file_path in selected_files + ] + + if len(set(validated_paths)) != len(validated_paths): + raise ValueError("Duplicate candidate file paths are not allowed.") + + destination = Path(destination).resolve() + + if destination.exists(): + raise FileExistsError("Candidate destination already exists.") + + destination.parent.mkdir(parents=True, exist_ok=True) + destination.mkdir() + + auth_token = token or os.getenv("GITHUB_TOKEN") + downloaded_files = [] + + try: + for relative_path in validated_paths: + encoded_path = quote(relative_path.as_posix(), safe="/") + url = ( + f"https://raw.githubusercontent.com/" + f"{repository}/{commit_sha}/{encoded_path}" + ) + + headers = { + "User-Agent": "FiberLab-Guardian", + "Accept": "application/vnd.github.raw+json", + } + + if auth_token: + headers["Authorization"] = f"Bearer {auth_token}" + + request = Request(url, headers=headers, method="GET") + + try: + with urlopen(request, timeout=timeout) as response: + content = response.read() + + except HTTPError as error: + raise CandidateDownloadError( + f"GitHub returned HTTP {error.code} " + f"while downloading {relative_path}." + ) from error + + except (URLError, TimeoutError, OSError) as error: + raise CandidateDownloadError( + f"Unable to download {relative_path} from GitHub." + ) from error + + target = destination.joinpath(*relative_path.parts) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(content) + + downloaded_files.append(target) + + except Exception: + # Remove an incomplete candidate workspace. + import shutil + + shutil.rmtree(destination, ignore_errors=True) + raise + + return downloaded_files diff --git a/tests/test_guardian_candidate_downloader.py b/tests/test_guardian_candidate_downloader.py new file mode 100644 index 0000000..d309afe --- /dev/null +++ b/tests/test_guardian_candidate_downloader.py @@ -0,0 +1,156 @@ + +"""Tests for the Guardian GitHub candidate downloader.""" + +import io +from urllib.error import HTTPError, URLError + +import pytest + +from guardian.candidate_downloader import ( + CandidateDownloadError, + download_candidate_files, +) + +REPOSITORY = "CodingBunny06/FiberLab" +COMMIT_SHA = "a" * 40 + + +def test_downloads_candidate_file(monkeypatch, tmp_path): + content = b"def calculate_output_power():\n return 10\n" + + def mock_urlopen(request, timeout): + assert COMMIT_SHA in request.full_url + return io.BytesIO(content) + + monkeypatch.setattr( + "guardian.candidate_downloader.urlopen", + mock_urlopen, + ) + + destination = tmp_path / "candidate" + + downloaded = download_candidate_files( + REPOSITORY, + COMMIT_SHA, + ["physics/attenuation.py"], + destination, + ) + + expected_file = destination / "physics" / "attenuation.py" + + assert downloaded == [expected_file] + assert expected_file.read_bytes() == content + + +def test_downloads_multiple_candidate_files(monkeypatch, tmp_path): + responses = [b"first file", b"second file"] + + def mock_urlopen(request, timeout): + return io.BytesIO(responses.pop(0)) + + monkeypatch.setattr( + "guardian.candidate_downloader.urlopen", + mock_urlopen, + ) + + destination = tmp_path / "candidate" + + downloaded = download_candidate_files( + REPOSITORY, + COMMIT_SHA, + ["physics/attenuation.py", "visualization/signal_graphs.py"], + destination, + ) + + assert len(downloaded) == 2 + assert downloaded[0].read_bytes() == b"first file" + assert downloaded[1].read_bytes() == b"second file" + + +def test_rejects_path_traversal(tmp_path): + destination = tmp_path / "candidate" + + with pytest.raises(ValueError): + download_candidate_files( + REPOSITORY, + COMMIT_SHA, + ["../outside.py"], + destination, + ) + + assert not destination.exists() + + +def test_refuses_existing_destination(monkeypatch, tmp_path): + destination = tmp_path / "candidate" + destination.mkdir() + + with pytest.raises(FileExistsError): + download_candidate_files( + REPOSITORY, + COMMIT_SHA, + ["physics/attenuation.py"], + destination, + ) + + +def test_handles_github_http_error(monkeypatch, tmp_path): + def mock_urlopen(request, timeout): + raise HTTPError( + request.full_url, + 404, + "Not Found", + None, + None, + ) + + monkeypatch.setattr( + "guardian.candidate_downloader.urlopen", + mock_urlopen, + ) + + with pytest.raises(CandidateDownloadError): + download_candidate_files( + REPOSITORY, + COMMIT_SHA, + ["physics/attenuation.py"], + tmp_path / "candidate", + ) + + +def test_handles_network_error(monkeypatch, tmp_path): + def mock_urlopen(request, timeout): + raise URLError("Connection unavailable") + + monkeypatch.setattr( + "guardian.candidate_downloader.urlopen", + mock_urlopen, + ) + + with pytest.raises(CandidateDownloadError): + download_candidate_files( + REPOSITORY, + COMMIT_SHA, + ["physics/attenuation.py"], + tmp_path / "candidate", + ) + + +def test_rejects_invalid_commit_sha(tmp_path): + with pytest.raises(ValueError): + download_candidate_files( + REPOSITORY, + "invalid-sha", + ["physics/attenuation.py"], + tmp_path / "candidate", + ) + + +def test_rejects_absolute_file_path(tmp_path): + with pytest.raises(ValueError): + download_candidate_files( + REPOSITORY, + COMMIT_SHA, + ["/tmp/outside.py"], + tmp_path / "candidate", + ) From 0ac46a90e7f932407b9bbe865e54c404a6e92786 Mon Sep 17 00:00:00 2001 From: CodingBunny06 <210141639+CodingBunny06@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:51:16 +0530 Subject: [PATCH 28/44] feat: integrate GitHub PRs with Guardian pipeline --- guardian/github_integration.py | 76 +++++++++++ tests/test_guardian_github_integration.py | 158 ++++++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 guardian/github_integration.py create mode 100644 tests/test_guardian_github_integration.py diff --git a/guardian/github_integration.py b/guardian/github_integration.py new file mode 100644 index 0000000..31b9dee --- /dev/null +++ b/guardian/github_integration.py @@ -0,0 +1,76 @@ + +"""Connect GitHub pull requests to the FiberLab Guardian pipeline.""" + +from dataclasses import dataclass +from pathlib import Path + +from guardian.candidate_downloader import download_candidate_files +from guardian.github_client import ChangedFile, GitHubClient, PullRequestInfo +from guardian.reporter import GuardianReport +from guardian.runner import run_guardian + + +@dataclass +class PullRequestInspectionResult: + """Store the results of inspecting a GitHub pull request.""" + + pull_request: PullRequestInfo + changed_files: list[ChangedFile] + downloaded_files: list[Path] + report: GuardianReport + + +def inspect_pull_request( + repository: str, + pr_number: int, + project_root: Path, + candidate_workspace: Path, + integration_workspace: Path, + token: str | None = None, +) -> PullRequestInspectionResult: + """Download and statically inspect the files changed by a GitHub PR.""" + + client = GitHubClient(token=token) + + pull_request = client.get_pull_request(repository, pr_number) + changed_files = client.list_changed_files(repository, pr_number) + + # Deleted files have no current file content to download. + downloadable_files = [ + changed_file + for changed_file in changed_files + if changed_file.status != "removed" + ] + + if not downloadable_files: + raise ValueError("The pull request contains no downloadable files.") + + selected_paths = [ + changed_file.path for changed_file in downloadable_files + ] + + # A PR's head commit may belong to a contributor's fork. + download_repository = pull_request.head_repo or repository + + downloaded_files = download_candidate_files( + repository=download_repository, + commit_sha=pull_request.head_sha, + selected_files=selected_paths, + destination=candidate_workspace, + token=token, + ) + + report = run_guardian( + project_root=project_root, + candidate_root=candidate_workspace, + selected_files=selected_paths, + workspace_root=integration_workspace, + run_quality_checks=False, + ) + + return PullRequestInspectionResult( + pull_request=pull_request, + changed_files=changed_files, + downloaded_files=downloaded_files, + report=report, + ) diff --git a/tests/test_guardian_github_integration.py b/tests/test_guardian_github_integration.py new file mode 100644 index 0000000..597a9ef --- /dev/null +++ b/tests/test_guardian_github_integration.py @@ -0,0 +1,158 @@ + +"""Tests for GitHub pull request integration.""" + + +import pytest + +from guardian.github_client import ChangedFile, PullRequestInfo +from guardian.github_integration import inspect_pull_request +from guardian.reporter import GuardianReport + + +def test_inspects_pull_request_using_existing_guardian_pipeline( + monkeypatch, + tmp_path, +): + """Verify the complete orchestration without network access.""" + + pull_request = PullRequestInfo( + number=12, + title="Implement attenuation module", + state="open", + base_branch="main", + head_branch="feature/attenuation", + head_sha="a" * 40, + head_repo="contributor/FiberLab", + ) + + changed_files = [ + ChangedFile( + "physics/attenuation.py", "modified", 20, 5, 25 + ), + ChangedFile( + "physics/old_module.py", "removed", 0, 10, 10 + ), + ] + + class FakeGitHubClient: + def __init__(self, token=None): + assert token == "test-token" + + def get_pull_request(self, repository, pr_number): + assert repository == "CodingBunny06/FiberLab" + assert pr_number == 12 + return pull_request + + def list_changed_files(self, repository, pr_number): + return changed_files + + downloaded_path = tmp_path / "candidate" / "physics" / "attenuation.py" + expected_report = GuardianReport( + diagnostics=[], + passed=1, + failed=0, + pending=2, + ) + + download_calls = {} + runner_calls = {} + + def fake_download(**kwargs): + download_calls.update(kwargs) + return [downloaded_path] + + def fake_runner(**kwargs): + runner_calls.update(kwargs) + return expected_report + + monkeypatch.setattr( + "guardian.github_integration.GitHubClient", + FakeGitHubClient, + ) + monkeypatch.setattr( + "guardian.github_integration.download_candidate_files", + fake_download, + ) + monkeypatch.setattr( + "guardian.github_integration.run_guardian", + fake_runner, + ) + + result = inspect_pull_request( + repository="CodingBunny06/FiberLab", + pr_number=12, + project_root=tmp_path / "project", + candidate_workspace=tmp_path / "candidate", + integration_workspace=tmp_path / "integration", + token="test-token", + ) + + assert result.pull_request.number == 12 + assert len(result.changed_files) == 2 + assert result.downloaded_files == [downloaded_path] + assert result.report is expected_report + + assert download_calls["repository"] == "contributor/FiberLab" + assert download_calls["commit_sha"] == "a" * 40 + assert download_calls["selected_files"] == [ + "physics/attenuation.py" + ] + + assert runner_calls["selected_files"] == [ + "physics/attenuation.py" + ] + assert runner_calls["run_quality_checks"] is False + + +def test_rejects_pull_request_without_downloadable_files( + monkeypatch, + tmp_path, +): + """Verify deleted-only PRs do not start a download.""" + + class FakeGitHubClient: + def __init__(self, token=None): + pass + + def get_pull_request(self, repository, pr_number): + return PullRequestInfo( + number=13, + title="Remove old module", + state="open", + base_branch="main", + head_branch="feature/removal", + head_sha="b" * 40, + head_repo="contributor/FiberLab", + ) + + def list_changed_files(self, repository, pr_number): + return [ + ChangedFile( + "physics/old_module.py", + "removed", + 0, + 10, + 10, + ) + ] + + def unexpected_download(**kwargs): + pytest.fail("Downloader should not run for deleted-only PRs.") + + monkeypatch.setattr( + "guardian.github_integration.GitHubClient", + FakeGitHubClient, + ) + monkeypatch.setattr( + "guardian.github_integration.download_candidate_files", + unexpected_download, + ) + + with pytest.raises(ValueError, match="no downloadable files"): + inspect_pull_request( + repository="CodingBunny06/FiberLab", + pr_number=13, + project_root=tmp_path / "project", + candidate_workspace=tmp_path / "candidate", + integration_workspace=tmp_path / "integration", + ) From 118ccfb66d87e07dd2d83a925106989749c1b446 Mon Sep 17 00:00:00 2001 From: CodingBunny06 <210141639+CodingBunny06@users.noreply.github.com> Date: Wed, 11 Nov 2026 17:07:48 +0530 Subject: [PATCH 29/44] feat: add GitHub PR reporting for Guardian --- guardian/github_commenter.py | 172 ++++++++++++++++++++++++ tests/test_guardian_github_commenter.py | 168 +++++++++++++++++++++++ 2 files changed, 340 insertions(+) create mode 100644 guardian/github_commenter.py create mode 100644 tests/test_guardian_github_commenter.py diff --git a/guardian/github_commenter.py b/guardian/github_commenter.py new file mode 100644 index 0000000..fc669d9 --- /dev/null +++ b/guardian/github_commenter.py @@ -0,0 +1,172 @@ + +"""Publish FiberLab Guardian reports as GitHub pull request comments.""" + +import json +import os +import re +from dataclasses import dataclass +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +from guardian.reporter import GuardianReport + + +@dataclass +class GitHubCommentResult: + """Details of a successfully published GitHub comment.""" + + comment_id: int + comment_url: str + + +class GitHubCommentError(Exception): + """Raised when a Guardian comment cannot be published.""" + + +def _validate_repository(repository: str) -> None: + if not isinstance(repository, str) or not re.fullmatch( + r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", + repository, + ): + raise ValueError("Repository must use the owner/repository format.") + + +def _validate_pr_number(pr_number: int) -> None: + if ( + isinstance(pr_number, bool) + or not isinstance(pr_number, int) + or pr_number <= 0 + ): + raise ValueError("Pull request number must be a positive integer.") + + +def format_guardian_comment(report: GuardianReport) -> str: + """Format a Guardian report as a readable GitHub Markdown comment.""" + + lines = [ + "## πŸ›‘οΈ FiberLab Guardian Report", + "", + f"**Overall Status: {report.overall_status}**", + "", + "### Inspection Summary", + "", + f"- Passed: **{report.passed}**", + f"- Failed: **{report.failed}**", + f"- Pending: **{report.pending}**", + "", + "### Diagnostic Findings", + ] + + if not report.diagnostics: + lines.extend(["", "No diagnostic findings were generated."]) + + for diagnostic in report.diagnostics: + lines.extend( + [ + "", + f"#### {diagnostic.status}: `{diagnostic.file}`", + f"- **Severity:** {diagnostic.severity}", + f"- **Finding:** {diagnostic.message}", + f"- **Recommendation:** {diagnostic.recommendation}", + ] + ) + + lines.extend( + [ + "", + "---", + ( + "_Generated by FiberLab Guardian. This is an automated " + "inspection report, not an approval or merge decision._" + ), + ] +) + + comment = "\n".join(lines) + + if len(comment) > 60_000: + raise ValueError( + "Guardian report is too long to publish as one GitHub comment." + ) + + return comment + + +def publish_guardian_comment( + repository: str, + pr_number: int, + report: GuardianReport, + token: str | None = None, + timeout: int = 15, +) -> GitHubCommentResult: + """Publish a Guardian report as a top-level GitHub PR comment.""" + + _validate_repository(repository) + _validate_pr_number(pr_number) + + if isinstance(timeout, bool) or not isinstance(timeout, int) or timeout <= 0: + raise ValueError("Timeout must be a positive integer.") + + auth_token = token or os.getenv("GITHUB_TOKEN") + + if not auth_token: + raise ValueError( + "GitHub token is required. Set the GITHUB_TOKEN environment variable." + ) + + comment_body = format_guardian_comment(report) + + payload = json.dumps({"body": comment_body}).encode("utf-8") + + request = Request( + ( + "https://api.github.com/repos/" + f"{repository}/issues/{pr_number}/comments" + ), + data=payload, + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {auth_token}", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "FiberLab-Guardian", + "Content-Type": "application/json", + }, + method="POST", + ) + + try: + with urlopen(request, timeout=timeout) as response: + data = json.loads(response.read().decode("utf-8")) + + except HTTPError as error: + if error.code in (401, 403): + message = ( + "GitHub denied comment publishing. Check token permissions." + ) + else: + message = f"GitHub returned HTTP {error.code} while publishing." + + raise GitHubCommentError(message) from error + + except (URLError, TimeoutError, OSError) as error: + raise GitHubCommentError( + "Unable to connect to GitHub while publishing the comment." + ) from error + + except (json.JSONDecodeError, UnicodeDecodeError) as error: + raise GitHubCommentError( + "GitHub returned an invalid comment response." + ) from error + + if ( + not isinstance(data, dict) + or not isinstance(data.get("id"), int) + or isinstance(data.get("id"), bool) + or not isinstance(data.get("html_url"), str) + ): + raise GitHubCommentError("GitHub returned an unexpected comment response.") + + return GitHubCommentResult( + comment_id=data["id"], + comment_url=data["html_url"], + ) diff --git a/tests/test_guardian_github_commenter.py b/tests/test_guardian_github_commenter.py new file mode 100644 index 0000000..582108f --- /dev/null +++ b/tests/test_guardian_github_commenter.py @@ -0,0 +1,168 @@ + +"""Tests for Guardian GitHub PR comment publishing.""" + +import io +import json +from urllib.error import HTTPError, URLError + +import pytest + +from guardian.error_detector import Diagnostic +from guardian.github_commenter import ( + GitHubCommentError, + format_guardian_comment, + publish_guardian_comment, +) +from guardian.reporter import GuardianReport + + +def sample_report(): + return GuardianReport( + diagnostics=[ + Diagnostic( + status="PASS", + severity="INFO", + file="physics/attenuation.py", + message="Required function found.", + recommendation="No action required.", + ), + Diagnostic( + status="FAIL", + severity="HIGH", + file="physics/numerical_aperture.py", + message="Incorrect function signature.", + recommendation="Check the module contract.", + ), + Diagnostic( + status="PENDING", + severity="WAITING", + file="visualization/signal_graphs.py", + message="Required module is missing.", + recommendation="Wait for the module owner.", + ), + ], + passed=1, + failed=1, + pending=1, + ) + + +def test_formats_guardian_report_as_markdown(): + comment = format_guardian_comment(sample_report()) + + assert "FiberLab Guardian Report" in comment + assert "FAIL" in comment + assert "Passed: **1**" in comment + assert "Failed: **1**" in comment + assert "Pending: **1**" in comment + assert "physics/numerical_aperture.py" in comment + assert "Check the module contract." in comment + + +def test_rejects_invalid_repository(): + with pytest.raises(ValueError): + publish_guardian_comment( + "invalid-repository", + 12, + sample_report(), + token="test-token", + ) + + +def test_rejects_invalid_pr_number(): + with pytest.raises(ValueError): + publish_guardian_comment( + "CodingBunny06/FiberLab", + 0, + sample_report(), + token="test-token", + ) + + +def test_rejects_missing_token(monkeypatch): + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + + with pytest.raises(ValueError, match="GitHub token"): + publish_guardian_comment( + "CodingBunny06/FiberLab", + 12, + sample_report(), + ) + + +def test_publishes_comment_successfully(monkeypatch): + response_data = { + "id": 12345, + "html_url": "https://github.com/CodingBunny06/FiberLab/pull/12#issuecomment-12345", + } + + def mock_urlopen(request, timeout): + assert request.get_method() == "POST" + assert request.get_header("Authorization") == "Bearer test-token" + assert request.get_header("Content-type") == "application/json" + assert "/issues/12/comments" in request.full_url + + body = json.loads(request.data.decode("utf-8")) + assert "FiberLab Guardian Report" in body["body"] + + return io.BytesIO(json.dumps(response_data).encode("utf-8")) + + monkeypatch.setattr( + "guardian.github_commenter.urlopen", + mock_urlopen, + ) + + result = publish_guardian_comment( + "CodingBunny06/FiberLab", + 12, + sample_report(), + token="test-token", + ) + + assert result.comment_id == 12345 + assert result.comment_url == response_data["html_url"] + + +def test_handles_github_http_error(monkeypatch): + def mock_urlopen(request, timeout): + raise HTTPError( + request.full_url, + 403, + "Forbidden", + None, + None, + ) + + monkeypatch.setattr( + "guardian.github_commenter.urlopen", + mock_urlopen, + ) + + with pytest.raises( + GitHubCommentError, + match="denied comment publishing", + ): + publish_guardian_comment( + "CodingBunny06/FiberLab", + 12, + sample_report(), + token="test-token", + ) + + +def test_handles_network_error(monkeypatch): + def mock_urlopen(request, timeout): + raise URLError("Connection unavailable") + + monkeypatch.setattr( + "guardian.github_commenter.urlopen", + mock_urlopen, + ) + + with pytest.raises(GitHubCommentError, match="connect"): + publish_guardian_comment( + "CodingBunny06/FiberLab", + 12, + sample_report(), + token="test-token", + ) From 382d73820c10d2043f87af808cc8a937815f3c42 Mon Sep 17 00:00:00 2001 From: CodingBunny06 <210141639+CodingBunny06@users.noreply.github.com> Date: Thu, 12 Nov 2026 12:21:06 +0530 Subject: [PATCH 30/44] Build FiberLab Guardian inspection and integration system --- SECURITY.md | 64 ++++ app.py | 7 + components/page_layout.py | 2 +- components/result_display.py | 1 - components/validation.py | 1 - docs/integration/MODULE_CONTRACTS.md | 25 +- guardian/candidate_downloader.py | 18 +- guardian/error_detector.py | 1 - guardian/github_client.py | 35 +- guardian/github_commenter.py | 125 ++++--- guardian/github_integration.py | 5 +- guardian/inspector.py | 19 +- guardian/integration_engine.py | 90 ++++-- guardian/reporter.py | 2 - guardian/runner.py | 9 +- guardian/test_runner.py | 1 - pages/attenuation_laboratory.py | 4 +- pages/fiber_explorer.py | 6 +- pages/guardian_dashboard.py | 339 +++++++++++++++++++ pages/home.py | 35 +- pages/signal_propagation.py | 4 +- requirements.txt | 3 +- tests/test_app_structure.py | 30 +- tests/test_guardian_candidate_downloader.py | 9 +- tests/test_guardian_end_to_end.py | 9 +- tests/test_guardian_error_detector.py | 1 - tests/test_guardian_github_client.py | 14 +- tests/test_guardian_github_commenter.py | 341 ++++++++++++++++++-- tests/test_guardian_github_integration.py | 18 +- tests/test_guardian_inspector.py | 23 +- tests/test_guardian_integration_engine.py | 6 +- tests/test_guardian_pipeline.py | 1 - tests/test_guardian_report_quality.py | 1 - tests/test_guardian_reporter.py | 1 - tests/test_guardian_runner.py | 2 - tests/test_guardian_self_check.py | 11 +- tests/test_guardian_test_runner.py | 5 +- tests/test_module_contracts.py | 17 +- tests/test_result_display.py | 6 +- tests/test_validation.py | 1 - 40 files changed, 989 insertions(+), 303 deletions(-) create mode 100644 SECURITY.md create mode 100644 pages/guardian_dashboard.py diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..25a0cc7 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,64 @@ +# FiberLab Guardian β€” Security Notes + +## 1. Purpose + +FiberLab Guardian inspects candidate code changes, validates module contracts, prepares an isolated integration workspace, and runs permitted quality checks. + +It is designed as a project-level code inspection and integration assistant. It is not a hardened security sandbox. + +## 2. Implemented Protections + +The integration engine currently implements the following protections: + +- Rejects absolute candidate paths. +- Rejects candidate paths containing `..` traversal components. +- Checks candidate path components for symbolic links. +- Resolves candidate paths and verifies that they remain within the candidate root. +- Checks the destination path for an existing symbolic link before path resolution. +- Creates integration workspaces separately from the original project. +- Excludes Git metadata, virtual environments, caches, and other configured directories from workspace copies. + +## 3. Known Limitations + +### 3.1 Time-of-Check to Time-of-Use (TOCTOU) + +Candidate path validation, containment checks, and file copying are separate filesystem operations. + +A concurrent local process could modify a validated candidate or destination path between these operations. This may allow filesystem access outside the intended roots. + +**Status:** Known and accepted limitation for the current project scope. + +**Severity:** Medium. + +**Mitigation:** Guardian should only operate on trusted local workspaces. Stronger descriptor-relative filesystem operations and race-resistant copying may be considered in a future security-hardening phase. + +### 3.2 No OS-Level Execution Sandbox + +The integration workspace is a separate project copy, not an operating-system security boundary. + +Subprocess timeouts limit execution duration but do not prevent filesystem access, network access, or other potentially unsafe operations. + +Guardian must not execute untrusted pull-request code in the normal inspection workflow. + +## 4. Pull Request Inspection Policy + +- Download candidate files from the exact pull-request commit. +- Perform static inspection before integration. +- Do not execute untrusted pull-request code. +- Disable quality-check execution for untrusted PR candidates. +- Require human review before accepting or merging changes. + +## 5. Future Security Improvements + +Potential future improvements include: + +- Race-resistant filesystem operations. +- OS-level sandboxing for untrusted code execution. +- More comprehensive filesystem permission checks. +- Additional security-focused automated tests. + +## 6. Security Review Disclaimer + +Guardian reports are automated engineering assessments. A passing report does not guarantee that code is free from vulnerabilities, constitute security certification, or approve a pull request for merging. + +Human review remains mandatory. \ No newline at end of file diff --git a/app.py b/app.py index 0439196..8c576f6 100644 --- a/app.py +++ b/app.py @@ -6,6 +6,7 @@ from pages.attenuation_laboratory import render as render_attenuation_laboratory from pages.fiber_explorer import render as render_fiber_explorer +from pages.guardian_dashboard import render as render_guardian from pages.home import render as render_home from pages.signal_propagation import render as render_signal_propagation @@ -35,6 +36,12 @@ "icon": ":material/graphic_eq:", "url_path": "signal-propagation", }, + { + "render": render_guardian, + "title": "Guardian", + "icon": ":material/shield:", + "url_path": "guardian", + }, ) diff --git a/components/page_layout.py b/components/page_layout.py index f802322..6d0a6ab 100644 --- a/components/page_layout.py +++ b/components/page_layout.py @@ -14,4 +14,4 @@ def render_page_header(title: str, caption: str, description: str) -> None: def render_workspace(message: str) -> None: """Render a standard laboratory workspace section.""" st.subheader("Laboratory workspace") - st.info(message) \ No newline at end of file + st.info(message) diff --git a/components/result_display.py b/components/result_display.py index 1e3c9b6..dcb974c 100644 --- a/components/result_display.py +++ b/components/result_display.py @@ -1,4 +1,3 @@ - """Reusable result display components for FiberLab.""" import streamlit as st diff --git a/components/validation.py b/components/validation.py index 4c53bee..68f38cf 100644 --- a/components/validation.py +++ b/components/validation.py @@ -1,4 +1,3 @@ - """Input validation utilities for FiberLab.""" import math diff --git a/docs/integration/MODULE_CONTRACTS.md b/docs/integration/MODULE_CONTRACTS.md index fed7aaf..58f4e03 100644 --- a/docs/integration/MODULE_CONTRACTS.md +++ b/docs/integration/MODULE_CONTRACTS.md @@ -88,11 +88,7 @@ File: `physics/numerical_aperture.py` Function: ```python -def calculate_numerical_aperture( - n_core: float, - n_cladding: float -) -> float: - ... +def calculate_numerical_aperture(n_core: float, n_cladding: float) -> float: ... ``` Inputs: @@ -117,11 +113,7 @@ File: `physics/numerical_aperture.py` Function: ```python -def calculate_critical_angle( - n_core: float, - n_cladding: float -) -> float: - ... +def calculate_critical_angle(n_core: float, n_cladding: float) -> float: ... ``` Output: @@ -134,11 +126,8 @@ File: `physics/attenuation.py` Function: ```python def calculate_output_power( - input_power_mw: float, - attenuation_db_per_km: float, - length_km: float -) -> float: - ... + input_power_mw: float, attenuation_db_per_km: float, length_km: float +) -> float: ... ``` Output: @@ -150,11 +139,7 @@ File: `physics/attenuation.py` Function: ```python -def calculate_total_loss( - attenuation_db_per_km: float, - length_km: float -) -> float: - ... +def calculate_total_loss(attenuation_db_per_km: float, length_km: float) -> float: ... ``` Output: diff --git a/guardian/candidate_downloader.py b/guardian/candidate_downloader.py index 950fdda..b724ca0 100644 --- a/guardian/candidate_downloader.py +++ b/guardian/candidate_downloader.py @@ -1,13 +1,16 @@ - """Download selected GitHub pull request files into an isolated workspace.""" import os import re +import shutil +import ssl from pathlib import Path, PurePosixPath from urllib.error import HTTPError, URLError from urllib.parse import quote from urllib.request import Request, urlopen +import certifi + class CandidateDownloadError(Exception): """Raised when a candidate file cannot be downloaded.""" @@ -67,10 +70,7 @@ def download_candidate_files( if isinstance(timeout, bool) or not isinstance(timeout, int) or timeout <= 0: raise ValueError("Timeout must be a positive integer.") - validated_paths = [ - _validate_file_path(file_path) - for file_path in selected_files - ] + validated_paths = [_validate_file_path(file_path) for file_path in selected_files] if len(set(validated_paths)) != len(validated_paths): raise ValueError("Duplicate candidate file paths are not allowed.") @@ -86,6 +86,7 @@ def download_candidate_files( auth_token = token or os.getenv("GITHUB_TOKEN") downloaded_files = [] + ssl_context = ssl.create_default_context(cafile=certifi.where()) try: for relative_path in validated_paths: encoded_path = quote(relative_path.as_posix(), safe="/") @@ -105,7 +106,11 @@ def download_candidate_files( request = Request(url, headers=headers, method="GET") try: - with urlopen(request, timeout=timeout) as response: + with urlopen( + request, + timeout=timeout, + context=ssl_context, + ) as response: content = response.read() except HTTPError as error: @@ -127,7 +132,6 @@ def download_candidate_files( except Exception: # Remove an incomplete candidate workspace. - import shutil shutil.rmtree(destination, ignore_errors=True) raise diff --git a/guardian/error_detector.py b/guardian/error_detector.py index 8a6f709..94b1ac5 100644 --- a/guardian/error_detector.py +++ b/guardian/error_detector.py @@ -1,4 +1,3 @@ - """Classify and explain findings produced by FiberLab Guardian.""" from dataclasses import dataclass diff --git a/guardian/github_client.py b/guardian/github_client.py index 9584812..fcc82ed 100644 --- a/guardian/github_client.py +++ b/guardian/github_client.py @@ -1,13 +1,15 @@ - """Read-only GitHub API client for FiberLab Guardian.""" import json import os import re +import ssl from dataclasses import dataclass from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen +import certifi + @dataclass class PullRequestInfo: @@ -80,8 +82,14 @@ def _request(self, endpoint: str) -> dict | list: method="GET", ) + ssl_context = ssl.create_default_context(cafile=certifi.where()) + try: - with urlopen(request, timeout=self.timeout) as response: + with urlopen( + request, + timeout=self.timeout, + context=ssl_context, + ) as response: return json.loads(response.read().decode("utf-8")) except HTTPError as error: @@ -96,19 +104,13 @@ def _request(self, endpoint: str) -> dict | list: "GitHub access was denied or rate-limited." ) from error - raise GitHubAPIError( - f"GitHub API returned HTTP {error.code}." - ) from error + raise GitHubAPIError(f"GitHub API returned HTTP {error.code}.") from error except (URLError, TimeoutError, OSError) as error: - raise GitHubAPIError( - "Unable to connect to the GitHub API." - ) from error + raise GitHubAPIError("Unable to connect to the GitHub API.") from error except (json.JSONDecodeError, UnicodeDecodeError) as error: - raise GitHubAPIError( - "GitHub returned an invalid JSON response." - ) from error + raise GitHubAPIError("GitHub returned an invalid JSON response.") from error def get_pull_request( self, @@ -120,9 +122,7 @@ def get_pull_request( self._validate_repository(repository) self._validate_pr_number(pr_number) - data = self._request( - f"/repos/{repository}/pulls/{pr_number}" - ) + data = self._request(f"/repos/{repository}/pulls/{pr_number}") if not isinstance(data, dict): raise GitHubAPIError("Unexpected pull request response.") @@ -139,9 +139,7 @@ def get_pull_request( head_branch=head["ref"], head_sha=head["sha"], head_repo=( - head_repo.get("full_name") - if isinstance(head_repo, dict) - else None + head_repo.get("full_name") if isinstance(head_repo, dict) else None ), ) @@ -160,8 +158,7 @@ def list_changed_files( while True: data = self._request( - f"/repos/{repository}/pulls/{pr_number}/files" - f"?per_page=100&page={page}" + f"/repos/{repository}/pulls/{pr_number}/files?per_page=100&page={page}" ) if not isinstance(data, list): diff --git a/guardian/github_commenter.py b/guardian/github_commenter.py index fc669d9..b860fbe 100644 --- a/guardian/github_commenter.py +++ b/guardian/github_commenter.py @@ -1,13 +1,16 @@ - """Publish FiberLab Guardian reports as GitHub pull request comments.""" import json import os import re +import ssl from dataclasses import dataclass from urllib.error import HTTPError, URLError +from urllib.parse import urlsplit from urllib.request import Request, urlopen +import certifi + from guardian.reporter import GuardianReport @@ -24,6 +27,7 @@ class GitHubCommentError(Exception): def _validate_repository(repository: str) -> None: + """Validate the GitHub owner/repository format.""" if not isinstance(repository, str) or not re.fullmatch( r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository, @@ -32,14 +36,53 @@ def _validate_repository(repository: str) -> None: def _validate_pr_number(pr_number: int) -> None: - if ( - isinstance(pr_number, bool) - or not isinstance(pr_number, int) - or pr_number <= 0 - ): + """Validate a positive pull request number.""" + if isinstance(pr_number, bool) or not isinstance(pr_number, int) or pr_number <= 0: raise ValueError("Pull request number must be a positive integer.") +def _validate_timeout(timeout: int) -> None: + """Validate the HTTP request timeout.""" + if isinstance(timeout, bool) or not isinstance(timeout, int) or timeout <= 0: + raise ValueError("Timeout must be a positive integer.") + + +def _validate_token(token: str | None) -> str: + """Retrieve and validate the GitHub authentication token.""" + auth_token = os.getenv("GITHUB_TOKEN") if token is None else token + + if not isinstance(auth_token, str) or not auth_token.strip(): + raise ValueError( + "A valid GitHub token is required. " + "Set the GITHUB_TOKEN environment variable." + ) + + if auth_token != auth_token.strip(): + raise ValueError("GitHub token must not contain surrounding whitespace.") + + return auth_token + + +def _validate_comment_url(comment_url: str) -> bool: + """Check that the returned URL belongs to GitHub.com over HTTPS.""" + if not isinstance(comment_url, str): + return False + + try: + parsed_url = urlsplit(comment_url) + + return ( + parsed_url.scheme == "https" + and parsed_url.hostname == "github.com" + and parsed_url.port in (None, 443) + and parsed_url.username is None + and parsed_url.password is None + and parsed_url.path.startswith("/") + ) + except ValueError: + return False + + def format_guardian_comment(report: GuardianReport) -> str: """Format a Guardian report as a readable GitHub Markdown comment.""" @@ -72,15 +115,15 @@ def format_guardian_comment(report: GuardianReport) -> str: ) lines.extend( - [ - "", - "---", - ( - "_Generated by FiberLab Guardian. This is an automated " - "inspection report, not an approval or merge decision._" - ), - ] -) + [ + "", + "---", + ( + "_Generated by FiberLab Guardian. This is an automated " + "inspection report, not an approval or merge decision._" + ), + ] + ) comment = "\n".join(lines) @@ -103,26 +146,15 @@ def publish_guardian_comment( _validate_repository(repository) _validate_pr_number(pr_number) + _validate_timeout(timeout) - if isinstance(timeout, bool) or not isinstance(timeout, int) or timeout <= 0: - raise ValueError("Timeout must be a positive integer.") - - auth_token = token or os.getenv("GITHUB_TOKEN") - - if not auth_token: - raise ValueError( - "GitHub token is required. Set the GITHUB_TOKEN environment variable." - ) + auth_token = _validate_token(token) comment_body = format_guardian_comment(report) - payload = json.dumps({"body": comment_body}).encode("utf-8") request = Request( - ( - "https://api.github.com/repos/" - f"{repository}/issues/{pr_number}/comments" - ), + f"https://api.github.com/repos/{repository}/issues/{pr_number}/comments", data=payload, headers={ "Accept": "application/vnd.github+json", @@ -134,17 +166,36 @@ def publish_guardian_comment( method="POST", ) + ssl_context = ssl.create_default_context(cafile=certifi.where()) + try: - with urlopen(request, timeout=timeout) as response: + with urlopen( + request, + timeout=timeout, + context=ssl_context, + ) as response: data = json.loads(response.read().decode("utf-8")) except HTTPError as error: - if error.code in (401, 403): - message = ( - "GitHub denied comment publishing. Check token permissions." - ) - else: - message = f"GitHub returned HTTP {error.code} while publishing." + response_body = error.read().decode("utf-8", errors="replace") + + try: + error_data = json.loads(response_body) + + if isinstance(error_data, dict): + github_message = error_data.get( + "message", + "Unknown GitHub error.", + ) + else: + github_message = "GitHub returned an unexpected error response." + + except json.JSONDecodeError: + github_message = "GitHub returned an unreadable error response." + + message = ( + f"GitHub returned HTTP {error.code} while publishing: {github_message}" + ) raise GitHubCommentError(message) from error @@ -162,7 +213,7 @@ def publish_guardian_comment( not isinstance(data, dict) or not isinstance(data.get("id"), int) or isinstance(data.get("id"), bool) - or not isinstance(data.get("html_url"), str) + or not _validate_comment_url(data.get("html_url")) ): raise GitHubCommentError("GitHub returned an unexpected comment response.") diff --git a/guardian/github_integration.py b/guardian/github_integration.py index 31b9dee..c9a52d2 100644 --- a/guardian/github_integration.py +++ b/guardian/github_integration.py @@ -1,4 +1,3 @@ - """Connect GitHub pull requests to the FiberLab Guardian pipeline.""" from dataclasses import dataclass @@ -45,9 +44,7 @@ def inspect_pull_request( if not downloadable_files: raise ValueError("The pull request contains no downloadable files.") - selected_paths = [ - changed_file.path for changed_file in downloadable_files - ] + selected_paths = [changed_file.path for changed_file in downloadable_files] # A PR's head commit may belong to a contributor's fork. download_repository = pull_request.head_repo or repository diff --git a/guardian/inspector.py b/guardian/inspector.py index 349cabd..08c837d 100644 --- a/guardian/inspector.py +++ b/guardian/inspector.py @@ -1,4 +1,3 @@ - """Inspect FiberLab modules against their agreed integration contracts.""" import ast @@ -99,9 +98,7 @@ def inspect_project(project_root: Path) -> list[InspectionFinding]: ) continue - actual_parameters = tuple( - argument.arg for argument in function.args.args - ) + actual_parameters = tuple(argument.arg for argument in function.args.args) if ( actual_parameters != expected_parameters @@ -188,10 +185,7 @@ def inspect_project(project_root: Path) -> list[InspectionFinding]: imports_streamlit = ( isinstance(node, ast.Import) and any(alias.name == "streamlit" for alias in node.names) - ) or ( - isinstance(node, ast.ImportFrom) - and node.module == "streamlit" - ) + ) or (isinstance(node, ast.ImportFrom) and node.module == "streamlit") if imports_streamlit: findings.append( @@ -207,7 +201,6 @@ def inspect_project(project_root: Path) -> list[InspectionFinding]: return findings - def inspect_internal_imports(project_root: Path) -> list[InspectionFinding]: """Detect broken internal module and symbol imports.""" findings = [] @@ -240,9 +233,7 @@ def inspect_internal_imports(project_root: Path) -> list[InspectionFinding]: if root_package not in project_packages: continue - module_path = project_root.joinpath( - *imported_module.split(".") - ) + module_path = project_root.joinpath(*imported_module.split(".")) target_file = module_path.with_suffix(".py") target_init = module_path / "__init__.py" @@ -319,6 +310,7 @@ def inspect_internal_imports(project_root: Path) -> list[InspectionFinding]: return findings + def inspect_package_syntax( project_root: Path, existing_findings: list[InspectionFinding], @@ -329,8 +321,7 @@ def inspect_package_syntax( already_reported = { finding.file for finding in existing_findings - if finding.status == "FAIL" - and "Unable to parse module" in finding.message + if finding.status == "FAIL" and "Unable to parse module" in finding.message } for package in ("physics", "visualization"): diff --git a/guardian/integration_engine.py b/guardian/integration_engine.py index a33dc5e..188a650 100644 --- a/guardian/integration_engine.py +++ b/guardian/integration_engine.py @@ -1,6 +1,6 @@ - """Build isolated workspaces for FiberLab Guardian integration checks.""" +import os import shutil from pathlib import Path @@ -24,8 +24,16 @@ def create_integration_workspace( destination: Path, ) -> Path: """Copy a project into an isolated workspace, excluding development files.""" - source_root = source_root.resolve() - destination = destination.resolve() + source_root = Path(source_root).resolve() + original_destination = Path(destination) + + # Reject existing destinations and symlinks before resolving. + if original_destination.is_symlink() or original_destination.exists(): + raise FileExistsError( + f"Integration workspace already exists: {original_destination}" + ) + + destination = original_destination.resolve() if not source_root.exists(): raise FileNotFoundError(f"Source directory does not exist: {source_root}") @@ -33,19 +41,42 @@ def create_integration_workspace( if not source_root.is_dir(): raise NotADirectoryError(f"Source path is not a directory: {source_root}") - if destination.exists(): - raise FileExistsError( - f"Integration workspace already exists: {destination}" - ) - if destination.is_relative_to(source_root): - raise ValueError( - "Integration workspace must be outside the source project." - ) + raise ValueError("Integration workspace must be outside the source project.") + + # Check only files and directories that will actually be copied. + for current_root, directory_names, file_names in os.walk( + source_root, + topdown=True, + followlinks=False, + ): + current_path = Path(current_root) + + # Exclude development directories before inspecting their contents. + directory_names[:] = [ + name for name in directory_names if name not in IGNORED_DIRECTORIES + ] - for path in source_root.rglob("*"): - if path.is_symlink(): - raise ValueError(f"Symlinks are not allowed in the source project: {path}") + # Reject symlink directories that would otherwise be copied. + for name in directory_names: + path = current_path / name + + if path.is_symlink(): + raise ValueError( + f"Symlinks are not allowed in the source project: {path}" + ) + + # Reject symlink files that would otherwise be copied. + for name in file_names: + if name in IGNORED_FILES or name.endswith(".pyc"): + continue + + path = current_path / name + + if path.is_symlink(): + raise ValueError( + f"Symlinks are not allowed in the source project: {path}" + ) def ignore_development_files( directory: str, @@ -55,7 +86,8 @@ def ignore_development_files( return { name for name in names - if name in IGNORED_DIRECTORIES or name in IGNORED_FILES + if name in IGNORED_DIRECTORIES + or name in IGNORED_FILES or name.endswith(".pyc") } @@ -67,19 +99,18 @@ def ignore_development_files( return destination + def integrate_candidate_modules( candidate_root: Path, workspace_root: Path, selected_files: list[str], ) -> list[Path]: """Copy selected candidate modules into an isolated integration workspace.""" - candidate_root = candidate_root.resolve() - workspace_root = workspace_root.resolve() + candidate_root = Path(candidate_root).resolve() + workspace_root = Path(workspace_root).resolve() if not candidate_root.is_dir(): - raise FileNotFoundError( - f"Candidate directory does not exist: {candidate_root}" - ) + raise FileNotFoundError(f"Candidate directory does not exist: {candidate_root}") if not workspace_root.is_dir(): raise FileNotFoundError( @@ -94,18 +125,23 @@ def integrate_candidate_modules( if relative_path.is_absolute() or ".." in relative_path.parts: raise ValueError(f"Unsafe candidate path: {relative_path}") - source_file = (candidate_root / relative_path).resolve() + # Check original path components before resolving symlinks. + candidate_path = candidate_root / relative_path + current_path = candidate_root + + for part in relative_path.parts: + current_path = current_path / part + + if current_path.is_symlink(): + raise ValueError(f"Symlinks are not allowed: {relative_path}") + + source_file = candidate_path.resolve() if not source_file.is_relative_to(candidate_root): raise ValueError(f"Unsafe candidate path: {relative_path}") if not source_file.is_file(): - raise FileNotFoundError( - f"Candidate file does not exist: {source_file}" - ) - - if source_file.is_symlink(): - raise ValueError(f"Symlinks are not allowed: {relative_path}") + raise FileNotFoundError(f"Candidate file does not exist: {source_file}") destination_file = workspace_root / relative_path destination_file.parent.mkdir(parents=True, exist_ok=True) diff --git a/guardian/reporter.py b/guardian/reporter.py index 1d3412a..b701c97 100644 --- a/guardian/reporter.py +++ b/guardian/reporter.py @@ -1,4 +1,3 @@ - """Generate consolidated reports from FiberLab Guardian inspections.""" from dataclasses import dataclass @@ -42,7 +41,6 @@ def generate_report(project_root: Path) -> GuardianReport: ) - def format_report(report: GuardianReport) -> str: """Format a Guardian report into a readable diagnostic summary.""" diff --git a/guardian/runner.py b/guardian/runner.py index 99f7e10..bac3435 100644 --- a/guardian/runner.py +++ b/guardian/runner.py @@ -1,4 +1,3 @@ - """Coordinate the FiberLab Guardian integration workflow.""" from pathlib import Path @@ -48,8 +47,6 @@ def run_guardian( pending=pending, ) - - if run_quality_checks: commands = [ ["ruff", "check", "."], @@ -78,6 +75,7 @@ def run_guardian( pending=pending, ) + def _command_diagnostic(result: CommandResult): """Convert an automated command result into a Guardian diagnostic.""" @@ -85,10 +83,7 @@ def _command_diagnostic(result: CommandResult): status="PASS" if result.status == "PASS" else "FAIL", severity="INFO" if result.status == "PASS" else "HIGH", file="integration workspace", - message=( - f"Automated check: {result.status} " - f"(exit code: {result.return_code})" - ), + message=(f"Automated check: {result.status} (exit code: {result.return_code})"), recommendation=( "No action required." if result.status == "PASS" diff --git a/guardian/test_runner.py b/guardian/test_runner.py index 04cdf61..5cd485d 100644 --- a/guardian/test_runner.py +++ b/guardian/test_runner.py @@ -1,4 +1,3 @@ - """Run automated quality checks for FiberLab Guardian.""" import subprocess diff --git a/pages/attenuation_laboratory.py b/pages/attenuation_laboratory.py index 7b48762..8f01d53 100644 --- a/pages/attenuation_laboratory.py +++ b/pages/attenuation_laboratory.py @@ -14,6 +14,4 @@ def render() -> None: ), ) - render_workspace( - "Interactive attenuation tools are coming soon." - ) \ No newline at end of file + render_workspace("Interactive attenuation tools are coming soon.") diff --git a/pages/fiber_explorer.py b/pages/fiber_explorer.py index eaa38fc..6add394 100644 --- a/pages/fiber_explorer.py +++ b/pages/fiber_explorer.py @@ -1,7 +1,5 @@ """Fiber Explorer page.""" - - from components.page_layout import render_page_header, render_workspace @@ -16,6 +14,4 @@ def render() -> None: ), ) - render_workspace( - "Interactive fiber exploration tools are coming soon." - ) \ No newline at end of file + render_workspace("Interactive fiber exploration tools are coming soon.") diff --git a/pages/guardian_dashboard.py b/pages/guardian_dashboard.py new file mode 100644 index 0000000..fb605ab --- /dev/null +++ b/pages/guardian_dashboard.py @@ -0,0 +1,339 @@ +"""FiberLab Guardian dashboard.""" + +import os +import tempfile +from pathlib import Path + +import streamlit as st + +from components.page_layout import render_page_header +from guardian.candidate_downloader import CandidateDownloadError +from guardian.github_client import GitHubAPIError +from guardian.github_commenter import ( + GitHubCommentError, + format_guardian_comment, + publish_guardian_comment, +) +from guardian.github_integration import inspect_pull_request +from guardian.reporter import format_report, generate_report + + +def render_report_findings(report) -> None: + """Render Guardian diagnostic findings.""" + + if not report.diagnostics: + st.info("No diagnostic findings were generated.") + return + + status_icons = { + "PASS": "βœ…", + "FAIL": "❌", + "PENDING": "⏳", + } + + for diagnostic in report.diagnostics: + icon = status_icons.get(diagnostic.status, "ℹ️") + + with st.expander( + f"{icon} {diagnostic.file} β€” {diagnostic.status}", + expanded=diagnostic.status == "FAIL", + ): + st.write(f"**Severity:** {diagnostic.severity}") + st.write(f"**Finding:** {diagnostic.message}") + st.write(f"**Recommendation:** {diagnostic.recommendation}") + + +def render_report_summary(report) -> None: + """Render the Guardian report status and summary metrics.""" + + if report.overall_status == "PASS": + st.success("Overall Status: PASS") + elif report.overall_status == "FAIL": + st.error("Overall Status: FAIL") + else: + st.warning("Overall Status: PENDING") + + passed_column, failed_column, pending_column = st.columns(3) + + passed_column.metric("Passed", report.passed) + failed_column.metric("Failed", report.failed) + pending_column.metric("Pending", report.pending) + + +def render_local_inspection() -> None: + """Render the local project inspection workflow.""" + + st.subheader("Local Project Inspection") + + st.write( + "Run Guardian against the current FiberLab project to check " + "module contracts, imports, syntax, and code structure." + ) + + st.info( + "Local inspection only. Guardian does not execute downloaded " + "pull-request code or modify project files." + ) + + if st.button( + "Run Guardian Inspection", + type="primary", + use_container_width=True, + key="guardian_local_inspection", + ): + project_root = Path(__file__).resolve().parents[1] + + with st.spinner("Inspecting FiberLab project..."): + try: + report = generate_report(project_root) + + st.session_state["guardian_report"] = report + st.session_state["guardian_report_text"] = format_report(report) + st.session_state["guardian_error"] = None + + except (OSError, ValueError, RuntimeError) as error: + st.session_state["guardian_error"] = str(error) + st.session_state.pop("guardian_report", None) + st.session_state.pop("guardian_report_text", None) + + error_message = st.session_state.get("guardian_error") + + if error_message: + st.error(f"Guardian inspection failed: {error_message}") + + report = st.session_state.get("guardian_report") + + if report is None: + st.caption("Run a local inspection to see its results.") + return + + st.divider() + st.subheader("Local Inspection Results") + + render_report_summary(report) + + st.subheader("Diagnostic Findings") + render_report_findings(report) + + st.subheader("Full Guardian Report") + + report_text = st.session_state["guardian_report_text"] + + st.download_button( + label="Download Inspection Report", + data=report_text, + file_name="fiberlab_guardian_report.txt", + mime="text/plain", + use_container_width=True, + key="guardian_local_download", + ) + + with st.expander("View full text report"): + st.code(report_text, language="text") + + +def render_github_inspection() -> None: + """Render GitHub pull request inspection and reporting.""" + + st.divider() + st.subheader("GitHub Pull Request Inspection") + + st.write( + "Inspect a pull request against the current FiberLab project. " + "Guardian downloads the changed files and checks them in an " + "isolated temporary integration workspace." + ) + + st.warning( + "PR inspection is static only. Downloaded pull-request code " + "is not executed. Publishing a report requires a separate " + "explicit action." + ) + + repository = st.text_input( + "GitHub Repository", + value="CodingBunny06/FiberLab", + help="Use the owner/repository format.", + key="guardian_github_repository", + ).strip() + + pr_number = st.number_input( + "Pull Request Number", + min_value=1, + step=1, + value=1, + key="guardian_github_pr_number", + ) + + if st.button( + "Inspect GitHub Pull Request", + type="primary", + use_container_width=True, + key="guardian_inspect_pr", + ): + project_root = Path(__file__).resolve().parents[1] + + st.session_state.pop("guardian_pr_result", None) + st.session_state.pop("guardian_pr_error", None) + st.session_state.pop("guardian_pr_comment_url", None) + st.session_state.pop("guardian_pr_publish_error", None) + + with st.spinner("Downloading and inspecting pull request..."): + try: + with tempfile.TemporaryDirectory( + prefix="fiberlab-guardian-" + ) as temporary_directory: + temporary_root = Path(temporary_directory) + + candidate_workspace = temporary_root / "candidate" + integration_workspace = temporary_root / "integration" + + result = inspect_pull_request( + repository=repository, + pr_number=int(pr_number), + project_root=project_root, + candidate_workspace=candidate_workspace, + integration_workspace=integration_workspace, + ) + + comment = format_guardian_comment(result.report) + + st.session_state["guardian_pr_result"] = result + st.session_state["guardian_pr_comment"] = comment + st.session_state["guardian_pr_repository"] = repository + st.session_state["guardian_pr_number"] = int(pr_number) + + except ( + CandidateDownloadError, + GitHubAPIError, + GitHubCommentError, + OSError, + ValueError, + RuntimeError, + ) as error: + st.session_state["guardian_pr_error"] = str(error) + + error_message = st.session_state.get("guardian_pr_error") + + if error_message: + st.error(f"GitHub PR inspection failed: {error_message}") + + result = st.session_state.get("guardian_pr_result") + + if result is None: + st.caption("Inspect a pull request to see its results here.") + return + + st.divider() + st.subheader("Pull Request Details") + + pull_request = result.pull_request + + st.write(f"**Title:** {pull_request.title}") + st.write(f"**PR Number:** #{pull_request.number}") + st.write(f"**Source Branch:** `{pull_request.head_branch}`") + st.write(f"**Target Branch:** `{pull_request.base_branch}`") + st.write(f"**Commit:** `{pull_request.head_sha}`") + + st.subheader("Changed Files") + + changed_file_rows = [ + { + "File": changed_file.path, + "Status": changed_file.status, + "Additions": changed_file.additions, + "Deletions": changed_file.deletions, + "Changes": changed_file.changes, + } + for changed_file in result.changed_files + ] + + st.dataframe( + changed_file_rows, + use_container_width=True, + hide_index=True, + ) + + st.divider() + st.subheader("PR Inspection Results") + + render_report_summary(result.report) + + st.subheader("Diagnostic Findings") + render_report_findings(result.report) + + st.divider() + st.subheader("GitHub Comment Preview") + + comment = st.session_state["guardian_pr_comment"] + + with st.expander("Preview Markdown comment", expanded=True): + st.code(comment, language="markdown") + + st.download_button( + label="Download GitHub Comment", + data=comment, + file_name=f"fiberlab_guardian_pr_{pull_request.number}.md", + mime="text/markdown", + use_container_width=True, + key="guardian_pr_comment_download", + ) + + st.divider() + st.subheader("Publish Report to GitHub") + + st.info( + "Publishing creates a new comment on the pull request. " + "Guardian will not publish anything unless you click the button." + ) + + if st.button( + "Publish Guardian Report to PR", + type="primary", + use_container_width=True, + key="guardian_publish_pr", + ): + token = os.getenv("GITHUB_TOKEN") + + with st.spinner("Publishing Guardian report to GitHub..."): + try: + publish_result = publish_guardian_comment( + repository=st.session_state["guardian_pr_repository"], + pr_number=st.session_state["guardian_pr_number"], + report=result.report, + token=token, + ) + + st.session_state["guardian_pr_comment_url"] = publish_result.comment_url + st.session_state["guardian_pr_publish_error"] = None + + except (GitHubCommentError, OSError, ValueError) as error: + st.session_state.pop("guardian_pr_comment_url", None) + st.session_state["guardian_pr_publish_error"] = str(error) + + publish_error = st.session_state.get("guardian_pr_publish_error") + + if publish_error: + st.error(f"Comment publishing failed: {publish_error}") + + comment_url = st.session_state.get("guardian_pr_comment_url") + + if comment_url: + st.success("Guardian report published successfully!") + st.markdown(f"[Open published GitHub comment]({comment_url})") + + +def render() -> None: + """Render the FiberLab Guardian dashboard.""" + + render_page_header( + title="FiberLab Guardian", + caption="Automated Code Inspection & Diagnostics", + description=( + "Inspect FiberLab code, identify structural issues, " + "and review automated diagnostic reports." + ), + ) + + render_local_inspection() + render_github_inspection() diff --git a/pages/home.py b/pages/home.py index 88a1449..890699f 100644 --- a/pages/home.py +++ b/pages/home.py @@ -1,4 +1,3 @@ - """FiberLab landing page.""" import streamlit as st @@ -23,35 +22,35 @@ def render() -> None: st.markdown("### Explore the Laboratory") capabilities = [ - ( - "πŸ”­ Fiber Explorer", ( - "Understand optical fiber geometry, refractive indices, " - "numerical aperture, acceptance angle, and total internal reflection." + "πŸ”­ Fiber Explorer", + ( + "Understand optical fiber geometry, refractive indices, " + "numerical aperture, acceptance angle, and total internal reflection." + ), ), - ), - ( - "πŸ“‰ Attenuation Laboratory", ( - "Investigate optical power loss over fiber length and " - "study how attenuation affects signal strength." + "πŸ“‰ Attenuation Laboratory", + ( + "Investigate optical power loss over fiber length and " + "study how attenuation affects signal strength." + ), ), - ), - ( - "〰️ Signal Propagation", ( - "Explore signal transmission, waveform behaviour, " - "and the fundamentals of pulse broadening." + "〰️ Signal Propagation", + ( + "Explore signal transmission, waveform behaviour, " + "and the fundamentals of pulse broadening." + ), ), - ), ] columns = st.columns(3) for column, (title, description) in zip(columns, capabilities): with column, st.container(border=True): - st.markdown(f"#### {title}") - st.write(description) + st.markdown(f"#### {title}") + st.write(description) st.divider() diff --git a/pages/signal_propagation.py b/pages/signal_propagation.py index 99dda33..cf6922b 100644 --- a/pages/signal_propagation.py +++ b/pages/signal_propagation.py @@ -14,6 +14,4 @@ def render() -> None: ), ) - render_workspace( - "Interactive signal propagation tools are coming soon." - ) \ No newline at end of file + render_workspace("Interactive signal propagation tools are coming soon.") diff --git a/requirements.txt b/requirements.txt index fda743d..4fa9448 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,5 @@ streamlit==1.67.0 plotly matplotlib pytest -ruff \ No newline at end of file +ruff +certifi diff --git a/tests/test_app_structure.py b/tests/test_app_structure.py index 0f49f5a..ac0aafb 100644 --- a/tests/test_app_structure.py +++ b/tests/test_app_structure.py @@ -24,8 +24,12 @@ def test_navigation_definitions_cover_all_pages(): "Fiber Explorer", "Attenuation Laboratory", "Signal Propagation", + "Guardian", ] - assert sum(definition.get("default", False) for definition in app.PAGE_DEFINITIONS) == 1 + assert ( + sum(definition.get("default", False) for definition in app.PAGE_DEFINITIONS) + == 1 + ) def test_main_uses_native_sidebar_navigation(monkeypatch): @@ -44,10 +48,12 @@ def test_main_uses_native_sidebar_navigation(monkeypatch): monkeypatch.setattr( app.st, "navigation", - lambda pages, **kwargs: calls.update( - {"pages": pages, "navigation_kwargs": kwargs} - ) - or type("Navigation", (), {"run": lambda self: calls.setdefault("ran", True)})(), + lambda pages, **kwargs: ( + calls.update({"pages": pages, "navigation_kwargs": kwargs}) + or type( + "Navigation", (), {"run": lambda self: calls.setdefault("ran", True)} + )() + ), ) app.main() @@ -59,6 +65,7 @@ def test_main_uses_native_sidebar_navigation(monkeypatch): "Fiber Explorer", "Attenuation Laboratory", "Signal Propagation", + "Guardian", ] assert calls["ran"] is True @@ -68,7 +75,10 @@ def test_landing_page_renders(): assert not app_test.exception assert [element.value for element in app_test.title] == ["FiberLab"] - assert any("Explore the Laboratory" in element.value for element in app_test.markdown) + assert any( + "Explore the Laboratory" in element.value for element in app_test.markdown + ) + @pytest.mark.parametrize( ("module_name", "title"), @@ -85,10 +95,14 @@ def test_simulator_pages_render_placeholders(monkeypatch, module_name, title): import streamlit as st monkeypatch.setattr(st, "title", lambda value: rendered.setdefault("title", value)) - monkeypatch.setattr(st, "caption", lambda value: rendered.setdefault("caption", value)) + monkeypatch.setattr( + st, "caption", lambda value: rendered.setdefault("caption", value) + ) monkeypatch.setattr(st, "write", lambda value: rendered.setdefault("body", value)) monkeypatch.setattr(st, "divider", lambda: None) - monkeypatch.setattr(st, "subheader", lambda value: rendered.setdefault("subheader", value)) + monkeypatch.setattr( + st, "subheader", lambda value: rendered.setdefault("subheader", value) + ) monkeypatch.setattr(st, "info", lambda value: rendered.setdefault("info", value)) module.render() diff --git a/tests/test_guardian_candidate_downloader.py b/tests/test_guardian_candidate_downloader.py index d309afe..03c94e1 100644 --- a/tests/test_guardian_candidate_downloader.py +++ b/tests/test_guardian_candidate_downloader.py @@ -1,4 +1,3 @@ - """Tests for the Guardian GitHub candidate downloader.""" import io @@ -18,7 +17,7 @@ def test_downloads_candidate_file(monkeypatch, tmp_path): content = b"def calculate_output_power():\n return 10\n" - def mock_urlopen(request, timeout): + def mock_urlopen(request, timeout, context): assert COMMIT_SHA in request.full_url return io.BytesIO(content) @@ -45,7 +44,7 @@ def mock_urlopen(request, timeout): def test_downloads_multiple_candidate_files(monkeypatch, tmp_path): responses = [b"first file", b"second file"] - def mock_urlopen(request, timeout): + def mock_urlopen(request, timeout, context): return io.BytesIO(responses.pop(0)) monkeypatch.setattr( @@ -95,7 +94,7 @@ def test_refuses_existing_destination(monkeypatch, tmp_path): def test_handles_github_http_error(monkeypatch, tmp_path): - def mock_urlopen(request, timeout): + def mock_urlopen(request, timeout, context): raise HTTPError( request.full_url, 404, @@ -119,7 +118,7 @@ def mock_urlopen(request, timeout): def test_handles_network_error(monkeypatch, tmp_path): - def mock_urlopen(request, timeout): + def mock_urlopen(request, timeout, context): raise URLError("Connection unavailable") monkeypatch.setattr( diff --git a/tests/test_guardian_end_to_end.py b/tests/test_guardian_end_to_end.py index b0372ad..402b11b 100644 --- a/tests/test_guardian_end_to_end.py +++ b/tests/test_guardian_end_to_end.py @@ -1,7 +1,5 @@ - """End-to-end tests for the FiberLab Guardian pipeline.""" - from guardian.runner import run_guardian from guardian.test_runner import CommandResult @@ -58,9 +56,7 @@ def test_guardian_stops_after_structural_failure(tmp_path, monkeypatch): physics_dir = candidate / "physics" physics_dir.mkdir() - (physics_dir / "numerical_aperture.py").write_text( - "def broken_function(\n" - ) + (physics_dir / "numerical_aperture.py").write_text("def broken_function(\n") def unexpected_command(*args, **kwargs): raise AssertionError("Quality checks should not execute.") @@ -117,6 +113,5 @@ def mock_run_command(command, workspace_root, timeout_seconds): assert report.failed == 1 assert report.overall_status == "FAIL" assert any( - "Ruff detected an issue." in item.recommendation - for item in report.diagnostics + "Ruff detected an issue." in item.recommendation for item in report.diagnostics ) diff --git a/tests/test_guardian_error_detector.py b/tests/test_guardian_error_detector.py index 9800db8..0934e94 100644 --- a/tests/test_guardian_error_detector.py +++ b/tests/test_guardian_error_detector.py @@ -1,4 +1,3 @@ - """Tests for the FiberLab Guardian error detector.""" from guardian.error_detector import analyze_findings, classify_finding diff --git a/tests/test_guardian_github_client.py b/tests/test_guardian_github_client.py index 69373e2..27bddd0 100644 --- a/tests/test_guardian_github_client.py +++ b/tests/test_guardian_github_client.py @@ -63,6 +63,7 @@ def test_github_client_rejects_invalid_pr_number(): 0, ) + def test_github_client_rejects_boolean_pr_number(): client = GitHubClient() @@ -82,6 +83,7 @@ def test_github_client_rejects_malformed_repository(): 12, ) + def test_get_pull_request_retrieves_metadata_and_uses_token(monkeypatch): """Verify PR metadata retrieval and authentication.""" @@ -97,7 +99,7 @@ def test_get_pull_request_retrieves_metadata_and_uses_token(monkeypatch): }, } - def mock_urlopen(request, timeout): + def mock_urlopen(request, timeout, context): assert request.get_header("Authorization") == "Bearer test-token" assert timeout == 15 return io.BytesIO(json.dumps(response_data).encode("utf-8")) @@ -140,11 +142,9 @@ def test_list_changed_files_handles_pagination(monkeypatch): responses = [first_page, second_page] requested_urls = [] - def mock_urlopen(request, timeout): + def mock_urlopen(request, timeout, context): requested_urls.append(request.full_url) - return io.BytesIO( - json.dumps(responses.pop(0)).encode("utf-8") - ) + return io.BytesIO(json.dumps(responses.pop(0)).encode("utf-8")) monkeypatch.setattr("guardian.github_client.urlopen", mock_urlopen) @@ -160,7 +160,7 @@ def mock_urlopen(request, timeout): def test_github_client_handles_http_errors(monkeypatch): """Verify GitHub HTTP errors are converted into API errors.""" - def mock_urlopen(request, timeout): + def mock_urlopen(request, timeout, context): raise HTTPError( request.full_url, 404, @@ -180,7 +180,7 @@ def mock_urlopen(request, timeout): def test_github_client_handles_network_errors(monkeypatch): """Verify network failures are handled cleanly.""" - def mock_urlopen(request, timeout): + def mock_urlopen(request, timeout, context): raise URLError("Connection unavailable") monkeypatch.setattr("guardian.github_client.urlopen", mock_urlopen) diff --git a/tests/test_guardian_github_commenter.py b/tests/test_guardian_github_commenter.py index 582108f..8a6f3a6 100644 --- a/tests/test_guardian_github_commenter.py +++ b/tests/test_guardian_github_commenter.py @@ -1,4 +1,3 @@ - """Tests for Guardian GitHub PR comment publishing.""" import io @@ -15,8 +14,17 @@ ) from guardian.reporter import GuardianReport +REPOSITORY = "CodingBunny06/FiberLab" +PR_NUMBER = 12 +TEST_TOKEN = "test-token" + +VALID_COMMENT_URL = ( + "https://github.com/CodingBunny06/FiberLab/pull/12#issuecomment-12345" +) + def sample_report(): + """Create a representative Guardian report.""" return GuardianReport( diagnostics=[ Diagnostic( @@ -47,6 +55,16 @@ def sample_report(): ) +def mock_successful_response(): + """Create a mock successful GitHub API response.""" + response_data = { + "id": 12345, + "html_url": VALID_COMMENT_URL, + } + + return io.BytesIO(json.dumps(response_data).encode("utf-8")) + + def test_formats_guardian_report_as_markdown(): comment = format_guardian_comment(sample_report()) @@ -57,25 +75,90 @@ def test_formats_guardian_report_as_markdown(): assert "Pending: **1**" in comment assert "physics/numerical_aperture.py" in comment assert "Check the module contract." in comment + assert "not an approval or merge decision" in comment + + +def test_formats_report_without_diagnostics(): + report = GuardianReport( + diagnostics=[], + passed=0, + failed=0, + pending=0, + ) + + comment = format_guardian_comment(report) + assert "No diagnostic findings were generated." in comment + + +def test_rejects_oversized_report(): + report = GuardianReport( + diagnostics=[ + Diagnostic( + status="FAIL", + severity="HIGH", + file="large_file.py", + message="x" * 61_000, + recommendation="Review the file.", + ) + ], + passed=0, + failed=1, + pending=0, + ) -def test_rejects_invalid_repository(): + with pytest.raises(ValueError, match="too long"): + format_guardian_comment(report) + + +@pytest.mark.parametrize( + "repository", + [ + "invalid-repository", + "", + "owner/", + "/repository", + "owner/repository/extra", + "owner/repo name", + None, + ], +) +def test_rejects_invalid_repository(repository): with pytest.raises(ValueError): publish_guardian_comment( - "invalid-repository", - 12, + repository, + PR_NUMBER, sample_report(), - token="test-token", + token=TEST_TOKEN, ) -def test_rejects_invalid_pr_number(): +@pytest.mark.parametrize( + "pr_number", + [0, -1, True, False, 1.5, "12"], +) +def test_rejects_invalid_pr_number(pr_number): with pytest.raises(ValueError): publish_guardian_comment( - "CodingBunny06/FiberLab", - 0, + REPOSITORY, + pr_number, + sample_report(), + token=TEST_TOKEN, + ) + + +@pytest.mark.parametrize( + "timeout", + [0, -1, True, False, 1.5, "15"], +) +def test_rejects_invalid_timeout(timeout): + with pytest.raises(ValueError, match="Timeout"): + publish_guardian_comment( + REPOSITORY, + PR_NUMBER, sample_report(), - token="test-token", + token=TEST_TOKEN, + timeout=timeout, ) @@ -84,28 +167,74 @@ def test_rejects_missing_token(monkeypatch): with pytest.raises(ValueError, match="GitHub token"): publish_guardian_comment( - "CodingBunny06/FiberLab", - 12, + REPOSITORY, + PR_NUMBER, sample_report(), ) -def test_publishes_comment_successfully(monkeypatch): - response_data = { - "id": 12345, - "html_url": "https://github.com/CodingBunny06/FiberLab/pull/12#issuecomment-12345", - } +@pytest.mark.parametrize("token", ["", " ", " ", None]) +def test_rejects_empty_token(token, monkeypatch): + monkeypatch.delenv("GITHUB_TOKEN", raising=False) - def mock_urlopen(request, timeout): + if token is None: + pytest.skip("None means use the environment variable.") + + with pytest.raises(ValueError, match="GitHub token"): + publish_guardian_comment( + REPOSITORY, + PR_NUMBER, + sample_report(), + token=token, + ) + + +def test_rejects_token_with_surrounding_whitespace(): + with pytest.raises(ValueError, match="surrounding whitespace"): + publish_guardian_comment( + REPOSITORY, + PR_NUMBER, + sample_report(), + token=" test-token ", + ) + + +def test_uses_environment_token(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", TEST_TOKEN) + + def mock_urlopen(request, timeout, context): + assert request.get_header("Authorization") == f"Bearer {TEST_TOKEN}" + return mock_successful_response() + + monkeypatch.setattr( + "guardian.github_commenter.urlopen", + mock_urlopen, + ) + + result = publish_guardian_comment( + REPOSITORY, + PR_NUMBER, + sample_report(), + ) + + assert result.comment_id == 12345 + + +def test_publishes_comment_successfully(monkeypatch): + def mock_urlopen(request, timeout, context): assert request.get_method() == "POST" - assert request.get_header("Authorization") == "Bearer test-token" + assert request.get_header("Authorization") == f"Bearer {TEST_TOKEN}" assert request.get_header("Content-type") == "application/json" - assert "/issues/12/comments" in request.full_url + assert request.get_header("Accept") == "application/vnd.github+json" + assert request.get_header("X-github-api-version") == "2022-11-28" + assert f"/issues/{PR_NUMBER}/comments" in request.full_url + assert timeout == 15 + assert context is not None body = json.loads(request.data.decode("utf-8")) assert "FiberLab Guardian Report" in body["body"] - return io.BytesIO(json.dumps(response_data).encode("utf-8")) + return mock_successful_response() monkeypatch.setattr( "guardian.github_commenter.urlopen", @@ -113,24 +242,152 @@ def mock_urlopen(request, timeout): ) result = publish_guardian_comment( - "CodingBunny06/FiberLab", - 12, + REPOSITORY, + PR_NUMBER, sample_report(), - token="test-token", + token=TEST_TOKEN, ) assert result.comment_id == 12345 - assert result.comment_url == response_data["html_url"] + assert result.comment_url == VALID_COMMENT_URL + + +@pytest.mark.parametrize( + "invalid_url", + [ + "http://github.com/owner/repo", + "https://evil.example.com/comment", + "https://github.com.evil.example.com/comment", + "https://evil@github.com/comment", + "https://github.com:444/comment", + "not-a-url", + "", + ], +) +def test_rejects_invalid_comment_url(monkeypatch, invalid_url): + response_data = { + "id": 12345, + "html_url": invalid_url, + } + + def mock_urlopen(request, timeout, context): + return io.BytesIO(json.dumps(response_data).encode("utf-8")) + + monkeypatch.setattr( + "guardian.github_commenter.urlopen", + mock_urlopen, + ) + + with pytest.raises(GitHubCommentError, match="unexpected comment response"): + publish_guardian_comment( + REPOSITORY, + PR_NUMBER, + sample_report(), + token=TEST_TOKEN, + ) + + +@pytest.mark.parametrize( + "response_data", + [ + {}, + {"html_url": VALID_COMMENT_URL}, + {"id": 12345}, + {"id": True, "html_url": VALID_COMMENT_URL}, + {"id": "12345", "html_url": VALID_COMMENT_URL}, + [], + None, + ], +) +def test_rejects_incomplete_or_invalid_response(monkeypatch, response_data): + def mock_urlopen(request, timeout, context): + return io.BytesIO(json.dumps(response_data).encode("utf-8")) + + monkeypatch.setattr( + "guardian.github_commenter.urlopen", + mock_urlopen, + ) + + with pytest.raises(GitHubCommentError, match="unexpected comment response"): + publish_guardian_comment( + REPOSITORY, + PR_NUMBER, + sample_report(), + token=TEST_TOKEN, + ) + + +def test_handles_malformed_json_response(monkeypatch): + def mock_urlopen(request, timeout, context): + return io.BytesIO(b"{invalid json") + + monkeypatch.setattr( + "guardian.github_commenter.urlopen", + mock_urlopen, + ) + + with pytest.raises(GitHubCommentError, match="invalid comment response"): + publish_guardian_comment( + REPOSITORY, + PR_NUMBER, + sample_report(), + token=TEST_TOKEN, + ) + + +def test_handles_invalid_unicode_response(monkeypatch): + def mock_urlopen(request, timeout, context): + return io.BytesIO(b"\xff\xfe") + + monkeypatch.setattr( + "guardian.github_commenter.urlopen", + mock_urlopen, + ) + + with pytest.raises(GitHubCommentError, match="invalid comment response"): + publish_guardian_comment( + REPOSITORY, + PR_NUMBER, + sample_report(), + token=TEST_TOKEN, + ) def test_handles_github_http_error(monkeypatch): - def mock_urlopen(request, timeout): + def mock_urlopen(request, timeout, context): raise HTTPError( request.full_url, 403, "Forbidden", None, + io.BytesIO(b'{"message": "Resource not accessible"}'), + ) + + monkeypatch.setattr( + "guardian.github_commenter.urlopen", + mock_urlopen, + ) + + with pytest.raises( + GitHubCommentError, + match="HTTP 403.*Resource not accessible", + ): + publish_guardian_comment( + REPOSITORY, + PR_NUMBER, + sample_report(), + token=TEST_TOKEN, + ) + + +def test_handles_unreadable_github_error_response(monkeypatch): + def mock_urlopen(request, timeout, context): + raise HTTPError( + request.full_url, + 500, + "Internal Server Error", None, + io.BytesIO(b"not json"), ) monkeypatch.setattr( @@ -140,18 +397,18 @@ def mock_urlopen(request, timeout): with pytest.raises( GitHubCommentError, - match="denied comment publishing", + match="HTTP 500.*unreadable error response", ): publish_guardian_comment( - "CodingBunny06/FiberLab", - 12, + REPOSITORY, + PR_NUMBER, sample_report(), - token="test-token", + token=TEST_TOKEN, ) def test_handles_network_error(monkeypatch): - def mock_urlopen(request, timeout): + def mock_urlopen(request, timeout, context): raise URLError("Connection unavailable") monkeypatch.setattr( @@ -161,8 +418,26 @@ def mock_urlopen(request, timeout): with pytest.raises(GitHubCommentError, match="connect"): publish_guardian_comment( - "CodingBunny06/FiberLab", - 12, + REPOSITORY, + PR_NUMBER, + sample_report(), + token=TEST_TOKEN, + ) + + +def test_handles_timeout(monkeypatch): + def mock_urlopen(request, timeout, context): + raise TimeoutError("Request timed out") + + monkeypatch.setattr( + "guardian.github_commenter.urlopen", + mock_urlopen, + ) + + with pytest.raises(GitHubCommentError, match="connect"): + publish_guardian_comment( + REPOSITORY, + PR_NUMBER, sample_report(), - token="test-token", + token=TEST_TOKEN, ) diff --git a/tests/test_guardian_github_integration.py b/tests/test_guardian_github_integration.py index 597a9ef..c5d73df 100644 --- a/tests/test_guardian_github_integration.py +++ b/tests/test_guardian_github_integration.py @@ -1,7 +1,5 @@ - """Tests for GitHub pull request integration.""" - import pytest from guardian.github_client import ChangedFile, PullRequestInfo @@ -26,12 +24,8 @@ def test_inspects_pull_request_using_existing_guardian_pipeline( ) changed_files = [ - ChangedFile( - "physics/attenuation.py", "modified", 20, 5, 25 - ), - ChangedFile( - "physics/old_module.py", "removed", 0, 10, 10 - ), + ChangedFile("physics/attenuation.py", "modified", 20, 5, 25), + ChangedFile("physics/old_module.py", "removed", 0, 10, 10), ] class FakeGitHubClient: @@ -94,13 +88,9 @@ def fake_runner(**kwargs): assert download_calls["repository"] == "contributor/FiberLab" assert download_calls["commit_sha"] == "a" * 40 - assert download_calls["selected_files"] == [ - "physics/attenuation.py" - ] + assert download_calls["selected_files"] == ["physics/attenuation.py"] - assert runner_calls["selected_files"] == [ - "physics/attenuation.py" - ] + assert runner_calls["selected_files"] == ["physics/attenuation.py"] assert runner_calls["run_quality_checks"] is False diff --git a/tests/test_guardian_inspector.py b/tests/test_guardian_inspector.py index 35e7961..50acf8e 100644 --- a/tests/test_guardian_inspector.py +++ b/tests/test_guardian_inspector.py @@ -1,4 +1,3 @@ - """Tests for the FiberLab Guardian code inspector.""" from pathlib import Path @@ -36,8 +35,7 @@ def calculate_critical_angle(n_core, n_cladding): findings = inspect_project(tmp_path) assert any( - finding.status == "PASS" - and "calculate_numerical_aperture" in finding.message + finding.status == "PASS" and "calculate_numerical_aperture" in finding.message for finding in findings ) @@ -58,8 +56,7 @@ def calculate_critical_angle(n_core, n_cladding): findings = inspect_project(tmp_path) assert any( - finding.status == "FAIL" - and "incompatible signature" in finding.message + finding.status == "FAIL" and "incompatible signature" in finding.message for finding in findings ) @@ -74,8 +71,7 @@ def test_syntax_error_is_detected(tmp_path): findings = inspect_project(tmp_path) assert any( - finding.status == "FAIL" - and "Unable to parse module" in finding.message + finding.status == "FAIL" and "Unable to parse module" in finding.message for finding in findings ) @@ -98,8 +94,7 @@ def calculate_critical_angle(n_core, n_cladding): findings = inspect_project(tmp_path) assert any( - finding.status == "FAIL" - and "Streamlit import" in finding.message + finding.status == "FAIL" and "Streamlit import" in finding.message for finding in findings ) @@ -117,8 +112,7 @@ def calculate_numerical_aperture(n_core, n_cladding): findings = inspect_project(tmp_path) assert any( - finding.status == "FAIL" - and "calculate_critical_angle" in finding.message + finding.status == "FAIL" and "calculate_critical_angle" in finding.message for finding in findings ) @@ -160,8 +154,7 @@ def calculate_critical_angle(n_core, n_cladding): findings = inspect_project(tmp_path) assert any( - finding.status == "FAIL" - and "physics.missing_module" in finding.message + finding.status == "FAIL" and "physics.missing_module" in finding.message for finding in findings ) @@ -187,11 +180,11 @@ def calculate_output_power(input_power_mw, attenuation_db_per_km, length_km): findings = inspect_project(tmp_path) assert any( - finding.status == "FAIL" - and "calculate_loss" in finding.message + finding.status == "FAIL" and "calculate_loss" in finding.message for finding in findings ) + def test_syntax_error_in_non_contract_module_is_detected(tmp_path): create_module( tmp_path, diff --git a/tests/test_guardian_integration_engine.py b/tests/test_guardian_integration_engine.py index 0ba02da..282dc7a 100644 --- a/tests/test_guardian_integration_engine.py +++ b/tests/test_guardian_integration_engine.py @@ -1,7 +1,5 @@ - """Tests for the FiberLab Guardian integration engine.""" - import pytest from guardian.integration_engine import ( @@ -20,9 +18,7 @@ def test_workspace_copies_project_files(tmp_path): result = create_integration_workspace(source, destination) assert result == destination - assert (destination / "app.py").read_text(encoding="utf-8") == ( - "print('FiberLab')" - ) + assert (destination / "app.py").read_text(encoding="utf-8") == ("print('FiberLab')") def test_workspace_excludes_development_directories(tmp_path): diff --git a/tests/test_guardian_pipeline.py b/tests/test_guardian_pipeline.py index 07b76b9..bdf7f9e 100644 --- a/tests/test_guardian_pipeline.py +++ b/tests/test_guardian_pipeline.py @@ -1,4 +1,3 @@ - """End-to-end tests for the FiberLab Guardian pipeline.""" from guardian.reporter import generate_report diff --git a/tests/test_guardian_report_quality.py b/tests/test_guardian_report_quality.py index 9ca680c..36d9242 100644 --- a/tests/test_guardian_report_quality.py +++ b/tests/test_guardian_report_quality.py @@ -1,4 +1,3 @@ - """Tests for Guardian report quality and diagnostics.""" from guardian.error_detector import Diagnostic diff --git a/tests/test_guardian_reporter.py b/tests/test_guardian_reporter.py index da876ec..37726d0 100644 --- a/tests/test_guardian_reporter.py +++ b/tests/test_guardian_reporter.py @@ -1,4 +1,3 @@ - """Tests for the FiberLab Guardian reporter.""" from guardian.reporter import format_report, generate_report diff --git a/tests/test_guardian_runner.py b/tests/test_guardian_runner.py index 06b9b63..006cf0a 100644 --- a/tests/test_guardian_runner.py +++ b/tests/test_guardian_runner.py @@ -1,7 +1,5 @@ - """Tests for the FiberLab Guardian execution pipeline.""" - from guardian.runner import run_guardian diff --git a/tests/test_guardian_self_check.py b/tests/test_guardian_self_check.py index 4252c7c..492d9f1 100644 --- a/tests/test_guardian_self_check.py +++ b/tests/test_guardian_self_check.py @@ -12,14 +12,9 @@ def calculate_numerical_aperture(n_core, cladding_index): module = ast.parse(incorrect_source) - function = next( - node for node in module.body - if isinstance(node, ast.FunctionDef) - ) + function = next(node for node in module.body if isinstance(node, ast.FunctionDef)) - actual_parameters = tuple( - argument.arg for argument in function.args.args - ) + actual_parameters = tuple(argument.arg for argument in function.args.args) assert actual_parameters != expected_parameters - assert actual_parameters == ("n_core", "cladding_index") \ No newline at end of file + assert actual_parameters == ("n_core", "cladding_index") diff --git a/tests/test_guardian_test_runner.py b/tests/test_guardian_test_runner.py index 1a347a3..1dd35fe 100644 --- a/tests/test_guardian_test_runner.py +++ b/tests/test_guardian_test_runner.py @@ -1,4 +1,3 @@ - """Tests for the FiberLab Guardian automated test runner.""" import subprocess @@ -71,9 +70,7 @@ def test_command_runs_in_requested_workspace(monkeypatch, tmp_path): def fake_run(*args, **kwargs): captured["cwd"] = kwargs["cwd"] - return subprocess.CompletedProcess( - args[0], 0, stdout="", stderr="" - ) + return subprocess.CompletedProcess(args[0], 0, stdout="", stderr="") monkeypatch.setattr(subprocess, "run", fake_run) diff --git a/tests/test_module_contracts.py b/tests/test_module_contracts.py index afc1e75..f1e3d05 100644 --- a/tests/test_module_contracts.py +++ b/tests/test_module_contracts.py @@ -1,4 +1,3 @@ - """Tests for FiberLab module integration contracts.""" import ast @@ -55,9 +54,7 @@ def test_visualization_package_exists(): def test_integration_contract_document_exists(): - assert ( - PROJECT_ROOT / "docs" / "integration" / "MODULE_CONTRACTS.md" - ).exists() + assert (PROJECT_ROOT / "docs" / "integration" / "MODULE_CONTRACTS.md").exists() def test_physics_package_does_not_import_streamlit(): @@ -97,9 +94,7 @@ def test_application_integration_contract_exists(): def test_visualization_package_does_not_import_streamlit(): - visualization_files = list( - (PROJECT_ROOT / "visualization").glob("*.py") - ) + visualization_files = list((PROJECT_ROOT / "visualization").glob("*.py")) for file_path in visualization_files: source = file_path.read_text(encoding="utf-8") @@ -137,9 +132,7 @@ def test_implemented_module_api_signatures(): ) function = definitions[function_name] - actual_parameters = tuple( - argument.arg for argument in function.args.args - ) + actual_parameters = tuple(argument.arg for argument in function.args.args) assert actual_parameters == expected_parameters, ( f"{function_name} in {relative_path} has incorrect " @@ -150,9 +143,7 @@ def test_implemented_module_api_signatures(): assert not function.args.posonlyargs, ( f"{function_name} must not use positional-only parameters." ) - assert function.args.vararg is None, ( - f"{function_name} must not use *args." - ) + assert function.args.vararg is None, f"{function_name} must not use *args." assert function.args.kwarg is None, ( f"{function_name} must not use **kwargs." ) diff --git a/tests/test_result_display.py b/tests/test_result_display.py index 0fec570..2d88508 100644 --- a/tests/test_result_display.py +++ b/tests/test_result_display.py @@ -1,4 +1,3 @@ - """Tests for FiberLab result display components.""" from unittest.mock import MagicMock @@ -12,10 +11,7 @@ def test_render_result_metrics_displays_empty_message(monkeypatch): result_display.render_result_metrics({}) - info_mock.assert_called_once_with( - "No simulation results available yet." - ) - + info_mock.assert_called_once_with("No simulation results available yet.") def test_render_result_metrics_displays_values(monkeypatch): diff --git a/tests/test_validation.py b/tests/test_validation.py index d26129e..301b85b 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -1,4 +1,3 @@ - """Tests for FiberLab input validation.""" import pytest From 58f86250463785b3dd1cb0973881a343dc18a98a Mon Sep 17 00:00:00 2001 From: CodingBunny06 <210141639+CodingBunny06@users.noreply.github.com> Date: Sat, 14 Nov 2026 12:09:03 +0530 Subject: [PATCH 31/44] docs: add FiberLab v1.0 integration documentation --- docs/integration/INTEGRATION_CHECKLIST.md | 50 ++++++++++++ docs/integration/MODULE_CONTRACTS.md | 13 +++- docs/release/RELEASE_CHECKLIST.md | 39 ++++++++++ docs/release/RELEASE_NOTES_v1.0.0.md | 40 ++++++++++ docs/review/PR_REVIEW_CHECKLIST.md | 36 +++++++++ docs/testing/INTEGRATION_TEST_PLAN.md | 92 +++++++++++++++++++++++ 6 files changed, 266 insertions(+), 4 deletions(-) create mode 100644 docs/integration/INTEGRATION_CHECKLIST.md create mode 100644 docs/release/RELEASE_CHECKLIST.md create mode 100644 docs/release/RELEASE_NOTES_v1.0.0.md create mode 100644 docs/review/PR_REVIEW_CHECKLIST.md create mode 100644 docs/testing/INTEGRATION_TEST_PLAN.md diff --git a/docs/integration/INTEGRATION_CHECKLIST.md b/docs/integration/INTEGRATION_CHECKLIST.md new file mode 100644 index 0000000..7ecefdd --- /dev/null +++ b/docs/integration/INTEGRATION_CHECKLIST.md @@ -0,0 +1,50 @@ +# FiberLab v1.0 Integration Checklist + +Mark an item complete only after verifying it against implementation and test evidence. + +## A. Contract agreement +- [ ] Physics owner reviewed and agreed to Section 8 of `MODULE_CONTRACTS.md`. +- [ ] Visualization owner reviewed and agreed to Section 9. +- [ ] Function names, parameters, return types, units, and exceptions are confirmed. +- [ ] Ray-data and waveform-data structures have documented examples. +- [ ] Unit conversions occur at the application boundary. + +## B. Physics integration +- [ ] Physics modules import without Streamlit. +- [ ] Numerical-aperture and critical-angle functions match the agreed contract. +- [ ] Attenuation functions match the agreed contract. +- [ ] Signal-propagation outputs have documented units and compatible dimensions. +- [ ] Invalid inputs raise documented exceptions. +- [ ] Unit tests cover normal, boundary, and invalid inputs. + +## C. Visualization integration +- [ ] Visualization consumes calculated data and does not reimplement physics. +- [ ] Fiber-ray function accepts the agreed ray-data structure. +- [ ] Attenuation graph receives compatible distance and power arrays. +- [ ] Signal graph receives compatible input, output, and time-axis arrays. +- [ ] Functions return Plotly Figure objects. +- [ ] Empty and invalid data are handled predictably. +- [ ] Tests inspect figure type, traces, labels, and data. + +## D. Application integration +- [ ] `app.py` owns navigation. +- [ ] Pages call the agreed physics API and pass results to the agreed visualization API. +- [ ] UI values are validated before calculations. +- [ ] UI-to-SI conversions occur at the application boundary. +- [ ] Errors are shown clearly without crashing the application. +- [ ] Physics and visualization modules do not depend on Streamlit. + +## E. Feature walkthrough +- [ ] Fiber Explorer: numerical aperture, critical angle, acceptance angle, and TIR conditions. +- [ ] Attenuation Laboratory: output power, total loss, and graph. +- [ ] Signal Propagation: input/output signal flow and comparison graph. +- [ ] Learning Lab: educational content and navigation. +- [ ] Navigation between all pages works. +- [ ] Valid, invalid, boundary, and empty inputs are exercised. + +## F. Final evidence +- [ ] Full test suite passes on the final integration commit. +- [ ] Ruff lint and format checks pass. +- [ ] CI passes on the exact commit proposed for merge. +- [ ] No unresolved high-severity findings remain. +- [ ] Final commit SHA and test results are recorded in the PR. diff --git a/docs/integration/MODULE_CONTRACTS.md b/docs/integration/MODULE_CONTRACTS.md index 58f4e03..6f624f5 100644 --- a/docs/integration/MODULE_CONTRACTS.md +++ b/docs/integration/MODULE_CONTRACTS.md @@ -159,6 +159,7 @@ Output: These signatures define the proposed v1.0 interface. Implementation remains the responsibility of the physics module owner. + ## 9. Visualization API β€” Function Specifications The visualization module converts simulation data into interactive visual outputs. @@ -168,27 +169,32 @@ The visualization module converts simulation data into interactive visual output File: `visualization/fiber_animation.py` Proposed function: + ```python def create_fiber_ray_figure(ray_data): ... +``` ### 9.2 Attenuation Graph File: `visualization/signal_graphs.py` Proposed function: + ```python def create_attenuation_figure( distance_km, power_mw ): ... +``` ### 9.3 Signal Waveform Graph File: `visualization/signal_graphs.py` Proposed function: + ```python def create_signal_waveform_figure( input_signal, @@ -202,10 +208,8 @@ Output: - Plotly Figure comparing input and output waveforms. Additional requirements: -- Input waveform, output waveform, and time-axis arrays - must have compatible lengths. -- The exact data types and units must be agreed upon - before implementation. +- Input waveform, output waveform, and time-axis arrays must have compatible lengths. +- The exact data types and units must be agreed upon before implementation. ### 9.4 General Requirements @@ -220,6 +224,7 @@ Additional requirements: These signatures are proposed v1.0 interfaces. The visualization module owner must review and agree to them before implementation. + ## 10. Application Integration API The application layer connects user inputs, physics calculations, and visualizations. diff --git a/docs/release/RELEASE_CHECKLIST.md b/docs/release/RELEASE_CHECKLIST.md new file mode 100644 index 0000000..fb2b326 --- /dev/null +++ b/docs/release/RELEASE_CHECKLIST.md @@ -0,0 +1,39 @@ +# FiberLab v1.0 Release Readiness Checklist + +## A. Scope freeze +- [ ] v1.0 feature scope is confirmed. +- [ ] Deferred features are excluded or labelled as future work. +- [ ] Physics and visualization contracts are approved by their owners. +- [ ] No unresolved duplicate or competing implementation PRs remain. + +## B. Code and integration +- [ ] Application pages use the agreed module APIs. +- [ ] Physics and visualization remain independent of Streamlit. +- [ ] Required v1.0 features are integrated. +- [ ] No placeholder is presented as a completed simulation. +- [ ] Error handling and input validation are verified. + +## C. Quality gates +- [ ] Ruff lint passes on the release-candidate commit. +- [ ] Ruff format check passes on the release-candidate commit. +- [ ] Pytest passes on the release-candidate commit. +- [ ] Manual feature walkthrough passes. +- [ ] CI passes on the exact release-candidate commit. +- [ ] No unresolved blocking or high-severity findings remain. + +## D. Documentation +- [ ] README accurately describes implemented features. +- [ ] Installation and launch instructions are verified. +- [ ] Module contracts reflect agreed interfaces. +- [ ] Known limitations are documented. +- [ ] Release notes match shipped implementation. +- [ ] Contributors and ownership are represented accurately. + +## E. Release approval +- [ ] Required PR reviews are complete. +- [ ] Project lead explicitly approves the merge. +- [ ] Main branch is verified after merge. +- [ ] Release tag is created only after final validation. +- [ ] Release artifacts and notes are published. + +Do not mark the release ready until every required gate has evidence. diff --git a/docs/release/RELEASE_NOTES_v1.0.0.md b/docs/release/RELEASE_NOTES_v1.0.0.md new file mode 100644 index 0000000..6b4e029 --- /dev/null +++ b/docs/release/RELEASE_NOTES_v1.0.0.md @@ -0,0 +1,40 @@ +# FiberLab v1.0.0 β€” Release Notes (Draft) + +> Draft only. Confirm every item against the final merged implementation before publishing. + +## Overview + +FiberLab is a Streamlit-based optical-fiber learning and simulation project. Version 1.0 is intended to provide a modular application structure for exploring optical-fiber concepts, with separate physics, visualization, and application layers. + +## Intended v1.0 feature areas + +- Fiber Explorer +- Attenuation Laboratory +- Basic Signal Propagation +- Learning Lab + +## Architecture + +- `physics/` β€” scientific calculations and simulation models. +- `visualization/` β€” Plotly-based visual representations of calculated data. +- `pages/` β€” Streamlit page-level interaction and orchestration. +- `components/` β€” reusable UI components. +- `guardian/` β€” project inspection and integration-support workflows. + +## Validation record + +Add the exact release-candidate commit SHA and verified results before publishing: + +- Ruff lint: +- Ruff format: +- Pytest: +- Manual application walkthrough: +- CI run: + +## Known limitations + +Document functionality that is incomplete, simplified, or deferred. Do not claim dispersion, modulation, BER, SNR, or eye-diagram capabilities unless those models are implemented and validated. + +## Contributors + +Add the final contributor list and contributions after confirming the merged work. diff --git a/docs/review/PR_REVIEW_CHECKLIST.md b/docs/review/PR_REVIEW_CHECKLIST.md new file mode 100644 index 0000000..431c814 --- /dev/null +++ b/docs/review/PR_REVIEW_CHECKLIST.md @@ -0,0 +1,36 @@ +# FiberLab Pull Request Review Checklist + +## Scope and ownership +- [ ] PR has a clear purpose and limited scope. +- [ ] Changed files belong to the contributor's assigned area, or cross-team changes were agreed. +- [ ] PR does not silently replace, duplicate, or conflict with another active PR. +- [ ] Description identifies dependencies and related PRs. + +## Architecture +- [ ] Physics remains independent of Streamlit. +- [ ] Visualization consumes calculated data rather than duplicating physics. +- [ ] Application pages orchestrate inputs, calculations, and display. +- [ ] Shared interfaces match `docs/integration/MODULE_CONTRACTS.md`. +- [ ] Units and angle conventions are consistent with the contract. + +## Code quality and security +- [ ] Names, type hints, and docstrings are clear. +- [ ] Invalid-input handling is explicit and consistent. +- [ ] No unnecessary duplication or unrelated changes. +- [ ] No secrets or credentials are committed. +- [ ] Untrusted PR code is not executed during static inspection. + +## Tests and automation +- [ ] Relevant tests were added or updated. +- [ ] Boundary and invalid-input cases are covered. +- [ ] Ruff lint and format checks pass. +- [ ] Pytest passes. +- [ ] CI results correspond to the latest PR head commit. + +## Review outcome +- [ ] No unresolved blocking findings. +- [ ] Required changes are addressed and re-reviewed. +- [ ] PR head SHA is checked after the final update. +- [ ] Project lead explicitly approves the merge. + +Green CI is necessary but does not replace code review. Do not merge automatically. diff --git a/docs/testing/INTEGRATION_TEST_PLAN.md b/docs/testing/INTEGRATION_TEST_PLAN.md new file mode 100644 index 0000000..d13cca2 --- /dev/null +++ b/docs/testing/INTEGRATION_TEST_PLAN.md @@ -0,0 +1,92 @@ +# FiberLab v1.0 Integration Test Plan + +## 1. Purpose + +Verify that the application, physics, visualization, and Guardian workflows operate together through their agreed interfaces. This is a test plan, not evidence that tests have already been executed. + +## 2. Test environment record + +- Operating system: +- Python version: +- Virtual environment: +- Streamlit version: +- Git commit SHA: +- Dependency installation command: +- Test date: + +## 3. Automated checks + +Run from the repository root: + +```bash +ruff check . +ruff format --check . +python -m pytest -v +``` + +Record exact output, test count, skipped tests, and commit SHA. Report a check as passing only if it completed successfully on the commit under review. + +## 4. Physics unit tests + +Verify valid refractive-index inputs; `n_core > n_cladding > 0`; numerical-aperture and critical-angle results; radians convention; attenuation and output-power calculations; positive power and fiber-length requirements; non-negative attenuation; invalid and non-finite values. + +## 5. Visualization tests + +Verify Plotly Figure return types; expected traces and data; axis titles and units; legends and titles; compatible array lengths; predictable handling of empty and invalid data. + +## 6. Application workflows + +### Fiber Explorer +- [ ] Page renders. +- [ ] Valid inputs produce numerical results. +- [ ] Invalid inputs produce understandable feedback. +- [ ] Ray visualization receives the agreed ray-data structure. + +### Attenuation Laboratory +- [ ] Page renders. +- [ ] Input power, attenuation, and length are validated. +- [ ] Output power and total loss are displayed. +- [ ] Graph data matches calculated values. + +### Signal Propagation +- [ ] Page renders. +- [ ] Input waveform and fiber parameters are validated. +- [ ] Output waveform is generated by the physics layer. +- [ ] Comparison visualization receives compatible arrays. + +### Learning Lab +- [ ] Page renders. +- [ ] Learning content is accessible. +- [ ] Navigation works. + +## 7. Guardian workflows + +- [ ] Local project inspection completes. +- [ ] Diagnostics show file and recommendation details. +- [ ] Integration workspace creation is tested. +- [ ] Candidate selection and integration are tested. +- [ ] Timeout and failure reporting are tested. +- [ ] GitHub PR inspection remains read-only unless explicit comment publishing is selected. +- [ ] Untrusted candidate code is not executed during static PR inspection. + +## 8. Manual regression + +- [ ] Launch application using documented command. +- [ ] Navigate through every page. +- [ ] Exercise valid, invalid, boundary, and empty inputs. +- [ ] Confirm displayed units and labels. +- [ ] Confirm normal workflows do not crash. +- [ ] Confirm Guardian actions match their confirmation text. + +## 9. Results record + +| Check | Result | Commit SHA | Notes | +|---|---|---|---| +| Ruff lint | Not run | β€” | | +| Ruff format | Not run | β€” | | +| Pytest | Not run | β€” | | +| App smoke test | Not run | β€” | | +| Feature walkthrough | Not run | β€” | | +| Guardian workflow | Not run | β€” | | + +Update this table only with observed results. From 3a9250bf2297fb2b433a7cfb9e7d61e9dae950e2 Mon Sep 17 00:00:00 2001 From: CodingBunny06 <210141639+CodingBunny06@users.noreply.github.com> Date: Sat, 14 Nov 2026 12:13:12 +0530 Subject: [PATCH 32/44] docs: fix contract markdown formatting --- docs/integration/MODULE_CONTRACTS.md | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/docs/integration/MODULE_CONTRACTS.md b/docs/integration/MODULE_CONTRACTS.md index 6f624f5..8407ed9 100644 --- a/docs/integration/MODULE_CONTRACTS.md +++ b/docs/integration/MODULE_CONTRACTS.md @@ -171,8 +171,7 @@ File: `visualization/fiber_animation.py` Proposed function: ```python -def create_fiber_ray_figure(ray_data): - ... +def create_fiber_ray_figure(ray_data): ... ``` ### 9.2 Attenuation Graph @@ -182,11 +181,7 @@ File: `visualization/signal_graphs.py` Proposed function: ```python -def create_attenuation_figure( - distance_km, - power_mw -): - ... +def create_attenuation_figure(distance_km, power_mw): ... ``` ### 9.3 Signal Waveform Graph @@ -196,12 +191,7 @@ File: `visualization/signal_graphs.py` Proposed function: ```python -def create_signal_waveform_figure( - input_signal, - output_signal, - time_axis -): - ... +def create_signal_waveform_figure(input_signal, output_signal, time_axis): ... ``` Output: From 08946aa6a3e934f3b66821e522fc3cfd3c3ccbda Mon Sep 17 00:00:00 2001 From: Rabbit <210141639+CodingBunny06@users.noreply.github.com> Date: Sat, 14 Nov 2026 12:17:16 +0530 Subject: [PATCH 33/44] fix: inspect all application packages for syntax and imports --- guardian/inspector.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/guardian/inspector.py b/guardian/inspector.py index 08c837d..30fed8e 100644 --- a/guardian/inspector.py +++ b/guardian/inspector.py @@ -204,7 +204,7 @@ def inspect_project(project_root: Path) -> list[InspectionFinding]: def inspect_internal_imports(project_root: Path) -> list[InspectionFinding]: """Detect broken internal module and symbol imports.""" findings = [] - project_packages = ("physics", "visualization", "components", "pages") + project_packages = ("physics", "visualization", "components", "pages", "guardian") for package in project_packages: package_path = project_root / package @@ -315,7 +315,7 @@ def inspect_package_syntax( project_root: Path, existing_findings: list[InspectionFinding], ) -> list[InspectionFinding]: - """Detect syntax errors in all physics and visualization Python files.""" + """Detect syntax errors across all application Python packages.""" findings = [] already_reported = { @@ -324,7 +324,7 @@ def inspect_package_syntax( if finding.status == "FAIL" and "Unable to parse module" in finding.message } - for package in ("physics", "visualization"): + for package in ("physics", "visualization", "components", "pages", "guardian"): package_path = project_root / package if not package_path.exists(): From c137f19188596c4063fdbe6a887161c40592b095 Mon Sep 17 00:00:00 2001 From: Rabbit <210141639+CodingBunny06@users.noreply.github.com> Date: Sat, 14 Nov 2026 12:17:19 +0530 Subject: [PATCH 34/44] test: cover syntax errors in Guardian package --- tests/test_guardian_inspector.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_guardian_inspector.py b/tests/test_guardian_inspector.py index 50acf8e..9c90656 100644 --- a/tests/test_guardian_inspector.py +++ b/tests/test_guardian_inspector.py @@ -237,3 +237,21 @@ def calculate_numerical_aperture(n_core, n_cladding): and "belongs in physics/numerical_aperture.py" in finding.message for finding in findings ) + + + +def test_syntax_error_in_guardian_module_is_detected(tmp_path): + create_module( + tmp_path, + "guardian/helpers.py", + "def inspect_candidate(:", + ) + + findings = inspect_project(tmp_path) + + assert any( + finding.status == "FAIL" + and finding.file == "guardian/helpers.py" + and "Unable to parse module" in finding.message + for finding in findings + ) From 3e427bef58043e1ab8ce914fcc07c13d4b82e7ea Mon Sep 17 00:00:00 2001 From: Rabbit <210141639+CodingBunny06@users.noreply.github.com> Date: Sat, 14 Nov 2026 12:17:32 +0530 Subject: [PATCH 35/44] fix: bound candidate downloads and reject symlink destinations --- guardian/candidate_downloader.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/guardian/candidate_downloader.py b/guardian/candidate_downloader.py index b724ca0..b94646a 100644 --- a/guardian/candidate_downloader.py +++ b/guardian/candidate_downloader.py @@ -12,6 +12,9 @@ import certifi +MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024 + + class CandidateDownloadError(Exception): """Raised when a candidate file cannot be downloaded.""" @@ -75,10 +78,12 @@ def download_candidate_files( if len(set(validated_paths)) != len(validated_paths): raise ValueError("Duplicate candidate file paths are not allowed.") - destination = Path(destination).resolve() + original_destination = Path(destination) + + if original_destination.is_symlink() or original_destination.exists(): + raise FileExistsError("Candidate destination already exists or is a symlink.") - if destination.exists(): - raise FileExistsError("Candidate destination already exists.") + destination = original_destination.resolve() destination.parent.mkdir(parents=True, exist_ok=True) destination.mkdir() @@ -111,7 +116,12 @@ def download_candidate_files( timeout=timeout, context=ssl_context, ) as response: - content = response.read() + content = response.read(MAX_FILE_SIZE_BYTES + 1) + if len(content) > MAX_FILE_SIZE_BYTES: + raise CandidateDownloadError( + f"Candidate file {relative_path} exceeds the " + f"{MAX_FILE_SIZE_BYTES}-byte download limit." + ) except HTTPError as error: raise CandidateDownloadError( From f43c6a05edbc31dd3b8e733bddd54ef7f645418a Mon Sep 17 00:00:00 2001 From: Rabbit <210141639+CodingBunny06@users.noreply.github.com> Date: Sat, 14 Nov 2026 12:17:35 +0530 Subject: [PATCH 36/44] test: cover downloader size and symlink safeguards --- tests/test_guardian_candidate_downloader.py | 44 +++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/test_guardian_candidate_downloader.py b/tests/test_guardian_candidate_downloader.py index 03c94e1..0b713ac 100644 --- a/tests/test_guardian_candidate_downloader.py +++ b/tests/test_guardian_candidate_downloader.py @@ -153,3 +153,47 @@ def test_rejects_absolute_file_path(tmp_path): ["/tmp/outside.py"], tmp_path / "candidate", ) + + + +def test_rejects_symlink_destination(tmp_path): + target = tmp_path / "outside" + destination = tmp_path / "candidate" + target.mkdir() + destination.symlink_to(target, target_is_directory=True) + + with pytest.raises(FileExistsError): + download_candidate_files( + REPOSITORY, + COMMIT_SHA, + ["physics/attenuation.py"], + destination, + ) + + assert list(target.iterdir()) == [] + + +def test_rejects_oversized_candidate_file(monkeypatch, tmp_path): + from guardian.candidate_downloader import MAX_FILE_SIZE_BYTES + + content = b"x" * (MAX_FILE_SIZE_BYTES + 1) + + def mock_urlopen(request, timeout, context): + return io.BytesIO(content) + + monkeypatch.setattr( + "guardian.candidate_downloader.urlopen", + mock_urlopen, + ) + + destination = tmp_path / "candidate" + + with pytest.raises(CandidateDownloadError, match="exceeds"): + download_candidate_files( + REPOSITORY, + COMMIT_SHA, + ["physics/attenuation.py"], + destination, + ) + + assert not destination.exists() From 150edbcfae4f621595841374d8d297f9513cff0a Mon Sep 17 00:00:00 2001 From: Rabbit <210141639+CodingBunny06@users.noreply.github.com> Date: Sat, 14 Nov 2026 12:17:43 +0530 Subject: [PATCH 37/44] fix: reject non-numeric values in input validation --- components/validation.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/components/validation.py b/components/validation.py index 68f38cf..53f950a 100644 --- a/components/validation.py +++ b/components/validation.py @@ -5,13 +5,23 @@ def validate_positive(value: float, name: str) -> None: """Ensure a value is finite and greater than zero.""" - if not math.isfinite(value) or value <= 0: + if ( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or not math.isfinite(value) + or value <= 0 + ): raise ValueError(f"{name} must be a finite positive number.") def validate_non_negative(value: float, name: str) -> None: """Ensure a value is finite and non-negative.""" - if not math.isfinite(value) or value < 0: + if ( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or not math.isfinite(value) + or value < 0 + ): raise ValueError(f"{name} must be a finite non-negative number.") From 0c6761af558b8a56764f1b65a14fe08ab26df36f Mon Sep 17 00:00:00 2001 From: Rabbit <210141639+CodingBunny06@users.noreply.github.com> Date: Sat, 14 Nov 2026 12:17:46 +0530 Subject: [PATCH 38/44] test: cover non-numeric validation inputs --- tests/test_validation.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_validation.py b/tests/test_validation.py index 301b85b..12b9450 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -44,3 +44,16 @@ def test_validate_refractive_indices_accepts_valid_values(): def test_validate_refractive_indices_rejects_invalid_order(): with pytest.raises(ValueError): validate_refractive_indices(1.4, 1.5) + + + +@pytest.mark.parametrize("value", ["1.5", None, True, object()]) +def test_validate_positive_rejects_non_numeric_values(value): + with pytest.raises(ValueError): + validate_positive(value, "Length") + + +@pytest.mark.parametrize("value", ["0", None, False, object()]) +def test_validate_non_negative_rejects_non_numeric_values(value): + with pytest.raises(ValueError): + validate_non_negative(value, "Attenuation") From 8486ae493f75b4120de076160afae7a9d0f882e0 Mon Sep 17 00:00:00 2001 From: Rabbit <210141639+CodingBunny06@users.noreply.github.com> Date: Sat, 14 Nov 2026 12:19:54 +0530 Subject: [PATCH 39/44] style: organize candidate downloader imports --- guardian/candidate_downloader.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/guardian/candidate_downloader.py b/guardian/candidate_downloader.py index b94646a..1cdc64c 100644 --- a/guardian/candidate_downloader.py +++ b/guardian/candidate_downloader.py @@ -1,5 +1,6 @@ """Download selected GitHub pull request files into an isolated workspace.""" +import certifi import os import re import shutil @@ -9,8 +10,6 @@ from urllib.parse import quote from urllib.request import Request, urlopen -import certifi - MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024 From 233b6eab1ae1919c2117c0fd7dfe419579d53b8a Mon Sep 17 00:00:00 2001 From: Rabbit <210141639+CodingBunny06@users.noreply.github.com> Date: Sat, 14 Nov 2026 12:30:12 +0530 Subject: [PATCH 40/44] fix: correct candidate downloader import ordering --- guardian/candidate_downloader.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/guardian/candidate_downloader.py b/guardian/candidate_downloader.py index 1cdc64c..b94646a 100644 --- a/guardian/candidate_downloader.py +++ b/guardian/candidate_downloader.py @@ -1,6 +1,5 @@ """Download selected GitHub pull request files into an isolated workspace.""" -import certifi import os import re import shutil @@ -10,6 +9,8 @@ from urllib.parse import quote from urllib.request import Request, urlopen +import certifi + MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024 From 53ae24f534f32edf0420173a389d0f9c0e686de8 Mon Sep 17 00:00:00 2001 From: Rabbit <210141639+CodingBunny06@users.noreply.github.com> Date: Sat, 14 Nov 2026 12:31:22 +0530 Subject: [PATCH 41/44] style: finalize candidate downloader import spacing --- guardian/candidate_downloader.py | 1 - 1 file changed, 1 deletion(-) diff --git a/guardian/candidate_downloader.py b/guardian/candidate_downloader.py index b94646a..a707a89 100644 --- a/guardian/candidate_downloader.py +++ b/guardian/candidate_downloader.py @@ -11,7 +11,6 @@ import certifi - MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024 From eecdc5864e348135c8d58e597844fdb0470fd70b Mon Sep 17 00:00:00 2001 From: Rabbit <210141639+CodingBunny06@users.noreply.github.com> Date: Sat, 14 Nov 2026 12:32:29 +0530 Subject: [PATCH 42/44] style: apply Ruff formatting to tests --- tests/test_guardian_candidate_downloader.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_guardian_candidate_downloader.py b/tests/test_guardian_candidate_downloader.py index 0b713ac..d0ebe15 100644 --- a/tests/test_guardian_candidate_downloader.py +++ b/tests/test_guardian_candidate_downloader.py @@ -155,7 +155,6 @@ def test_rejects_absolute_file_path(tmp_path): ) - def test_rejects_symlink_destination(tmp_path): target = tmp_path / "outside" destination = tmp_path / "candidate" From a40eb1aca20dcc721ff36e24f525ec703bd9a9f2 Mon Sep 17 00:00:00 2001 From: Rabbit <210141639+CodingBunny06@users.noreply.github.com> Date: Sat, 14 Nov 2026 12:32:32 +0530 Subject: [PATCH 43/44] style: apply Ruff formatting to tests --- tests/test_guardian_inspector.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_guardian_inspector.py b/tests/test_guardian_inspector.py index 9c90656..a6e7b54 100644 --- a/tests/test_guardian_inspector.py +++ b/tests/test_guardian_inspector.py @@ -239,7 +239,6 @@ def calculate_numerical_aperture(n_core, n_cladding): ) - def test_syntax_error_in_guardian_module_is_detected(tmp_path): create_module( tmp_path, From e6d080b76dd9ee30631b99bd346de013508e00bf Mon Sep 17 00:00:00 2001 From: Rabbit <210141639+CodingBunny06@users.noreply.github.com> Date: Sat, 14 Nov 2026 12:32:35 +0530 Subject: [PATCH 44/44] style: apply Ruff formatting to tests --- tests/test_validation.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_validation.py b/tests/test_validation.py index 12b9450..d6145cd 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -46,7 +46,6 @@ def test_validate_refractive_indices_rejects_invalid_order(): validate_refractive_indices(1.4, 1.5) - @pytest.mark.parametrize("value", ["1.5", None, True, object()]) def test_validate_positive_rejects_non_numeric_values(value): with pytest.raises(ValueError):