From 3d7b177eb0c5a7ae843b7782f5d22d04da8513c3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 04:56:31 -0700 Subject: [PATCH 01/22] ci: publish ontology through GitHub Pages --- .github/workflows/ontology-pages.yml | 135 +++++++ CHANGELOG.d/2.12.7-ontology-pages.md | 12 + docs/adr/0131-published-ontology-pages.md | 109 ++++++ docs/product-technical-gap-baseline.md | 12 +- scripts/build_ontology_site.py | 457 ++++++++++++++++++++++ scripts/publish_ontology_site.py | 155 ++++++++ tests/test_ontology_site.py | 211 ++++++++++ tests/test_publish_ontology_site.py | 185 +++++++++ 8 files changed, 1274 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ontology-pages.yml create mode 100644 CHANGELOG.d/2.12.7-ontology-pages.md create mode 100644 docs/adr/0131-published-ontology-pages.md create mode 100644 scripts/build_ontology_site.py create mode 100644 scripts/publish_ontology_site.py create mode 100644 tests/test_ontology_site.py create mode 100644 tests/test_publish_ontology_site.py diff --git a/.github/workflows/ontology-pages.yml b/.github/workflows/ontology-pages.yml new file mode 100644 index 000000000..43c5d384c --- /dev/null +++ b/.github/workflows/ontology-pages.yml @@ -0,0 +1,135 @@ +name: Ontology Pages + +on: + pull_request: + branches: [main] + paths: + - "docs/ontology/**" + - "scripts/build_ontology_site.py" + - "scripts/publish_ontology_site.py" + - "tests/test_ontology.py" + - "tests/test_ontology_site.py" + - "tests/test_publish_ontology_site.py" + - ".github/workflows/ontology-pages.yml" + - "pyproject.toml" + - "uv.lock" + push: + branches: [main] + paths: + - "docs/ontology/**" + - "scripts/build_ontology_site.py" + - "scripts/publish_ontology_site.py" + - "tests/test_ontology.py" + - "tests/test_ontology_site.py" + - "tests/test_publish_ontology_site.py" + - ".github/workflows/ontology-pages.yml" + - "pyproject.toml" + - "uv.lock" + workflow_dispatch: + +permissions: + contents: read + +jobs: + validate: + name: Validate ontology publication + if: github.event_name == 'pull_request' + concurrency: + group: ontology-pages-validation-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Set up locked Python dependency manager + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + version: "0.11.28" + enable-cache: false + + - name: Install committed dependencies + run: uv sync --frozen --extra dev + + - name: Verify ontology and publication contracts + run: | + uv run --frozen python -m pytest -q tests/test_ontology.py + uv run --frozen python -m coverage run --branch \ + -m pytest -q tests/test_ontology_site.py tests/test_publish_ontology_site.py + uv run --frozen python -m coverage report \ + --include=scripts/build_ontology_site.py,scripts/publish_ontology_site.py \ + --fail-under=100 + + - name: Build static ontology site + run: uv run --frozen python scripts/publish_ontology_site.py --output-dir _site + + - name: Compile owned Python surface + run: >- + uv run --frozen python -m compileall -q + scripts/build_ontology_site.py scripts/publish_ontology_site.py + tests/test_ontology_site.py tests/test_publish_ontology_site.py + + publish: + name: Publish ontology to GitHub Pages + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + concurrency: + group: ontology-pages-publication + cancel-in-progress: false + runs-on: ubuntu-latest + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Set up locked Python dependency manager + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + version: "0.11.28" + enable-cache: false + + - name: Install committed dependencies + run: uv sync --frozen --extra dev + + - name: Verify exact protected source before publication + run: | + uv run --frozen python -m pytest -q tests/test_ontology.py + uv run --frozen python -m coverage run --branch \ + -m pytest -q tests/test_ontology_site.py tests/test_publish_ontology_site.py + uv run --frozen python -m coverage report \ + --include=scripts/build_ontology_site.py,scripts/publish_ontology_site.py \ + --fail-under=100 + + - name: Build deterministic publication artifact + run: uv run --frozen python scripts/publish_ontology_site.py --output-dir _site + + - name: Configure GitHub Pages + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 + + - name: Upload GitHub Pages artifact + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: _site + + - name: Deploy GitHub Pages artifact + id: deployment + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/CHANGELOG.d/2.12.7-ontology-pages.md b/CHANGELOG.d/2.12.7-ontology-pages.md new file mode 100644 index 000000000..ba7d73518 --- /dev/null +++ b/CHANGELOG.d/2.12.7-ontology-pages.md @@ -0,0 +1,12 @@ +## Added + +- Added a deterministic GitHub Pages publication pipeline for the public + ontology documentation URL, with fragment-addressable terms and Turtle, + JSON-LD, N-Triples, PROV-O profile, and source-digest artifacts. +- Added semantic round-trip, byte-determinism, fail-closed source, CLI, and + 100% statement/branch coverage tests for the ontology site renderer. +- Added a fail-closed publication boundary that prevents duplicate public + fragments, unsafe linked IRI schemes, symlink or source-overlapping outputs, + and deletion of output directories not marked as generated. +- Restricted Pages deployment to `main`, preserved non-cancelling publication + concurrency, and kept all third-party Actions pinned by full commit SHA. diff --git a/docs/adr/0131-published-ontology-pages.md b/docs/adr/0131-published-ontology-pages.md new file mode 100644 index 000000000..51f4e7da3 --- /dev/null +++ b/docs/adr/0131-published-ontology-pages.md @@ -0,0 +1,109 @@ +# ADR 0131 — Publish the ontology namespace as a deterministic GitHub Pages artifact + +**Decision status:** Accepted +**Date:** 2026-08-21 + +## Context + +ADR 0004 established `docs/ontology/lineageweave-kg.ttl` as the formal, +machine-validated OWL 2 / RDF Schema / SKOS vocabulary for LineageWeave. The +repository already verifies that the ontology and relational controlled +vocabulary do not drift. However, the product-facing URL +`https://contextualwisdomlab.github.io/LineageWeave/ontology#` returned no +published resource, so ontology terms shown to buyers and external consumers +did not lead to a documentation endpoint. + +Publishing the authenticated LineageWeave application itself is not the right +fix. The ontology is a public specification artifact. It must remain usable +without tenant credentials, runtime APIs, PostgreSQL, contextual-orchestrator, +or any private source data. + +A second concern is namespace identity. The knowledge-graph Turtle and runtime +lookup predicate use the lowercase semantic namespace +`https://contextualwisdomlab.github.io/lineageweave/ontology#`, while the +committed PROV-O support profile and its contract test use the repository-case +namespace `https://contextualwisdomlab.github.io/LineageWeave/ontology#`. +GitHub Pages paths are case-sensitive. Silently rewriting either form would be +a breaking ontology migration, not a deployment repair. Issue #372 therefore +owns the inventory, canonical-namespace decision, compatibility vocabulary, +and consumer migration plan. + +## Decision + +1. Add a deterministic Python renderer, `scripts/build_ontology_site.py`, that + reads the authoritative Turtle source and emits a static Pages tree. +2. Publish a fragment-addressable HTML vocabulary at + `https://contextualwisdomlab.github.io/LineageWeave/ontology`, with one + stable anchor for every documented class, property, concept scheme, and + concept. A resource with more than one documented RDF type is rendered once + with one anchor. +3. Publish equivalent machine-readable artifacts beside the HTML: + `ontology.ttl`, `ontology.jsonld`, `ontology.nt`, the PROV-O support profile, + and a source-digest manifest. +4. Preserve `lineageweave-kg.ttl` byte-for-byte as the published Turtle + artifact. JSON-LD and N-Triples are generated from a canonicalized RDF graph + and are tested for semantic isomorphism with the source. +5. Do not add a build timestamp. The same source tree must produce the same + artifact bytes. The manifest records the source SHA-256 instead. +6. Run publication through `scripts/publish_ontology_site.py`, a fail-closed + boundary that rejects duplicate HTML fragments, non-HTTP(S) linked IRIs, + symlink outputs, source-overlapping outputs, and replacement of directories + that do not contain the generator marker. This prevents ontology data from + becoming executable links and prevents a misconfigured output path from + deleting unrelated files. +7. Validate publication behavior on pull requests, including 100% statement + and branch coverage for both the renderer and publication boundary. Deploy + only from `main`; a manual dispatch from any other ref is not a publication + path. +8. Pin every third-party GitHub Action by full commit SHA and grant Pages and + OIDC permissions only to the deployment job. Pull-request validation may + cancel superseded runs, while the single publication concurrency group does + not cancel an in-progress deployment. +9. Keep existing semantic IRIs unchanged in this deployment PR. The Pages + document distinguishes the public documentation endpoint from the semantic + identifier. Issue #372 and a future versioned ADR must govern any namespace + migration, compatibility mappings, deprecation interval, and stored-data + migration. +10. The repository must have Pages source set to **GitHub Actions** once. After + that administrative enablement, publication is entirely workflow-driven. + +## Consequences + +- The requested URL becomes a stable public specification surface after this + change reaches `main`, the repository Pages source is configured for GitHub + Actions, and the Pages environment completes successfully. +- External consumers can inspect human-readable terms or download equivalent + RDF serializations without running LineageWeave. +- A changed ontology cannot publish if its lookup-code contract, semantic + round-trip, deterministic-build contract, public-link safety, unique-fragment + contract, filesystem replacement boundary, or coverage gate fails. +- GitHub Pages remains a static documentation host; it does not provide HTTP + content negotiation or become a graph database, SPARQL endpoint, or source + of runtime truth. +- No private tenant data, runtime secrets, model output, or authenticated UI is + present in the artifact. +- The existing case-distinct namespace forms remain a tracked interoperability + gap rather than being hidden by this deployment change. + +## Related decisions and work + +- [ADR 0004](0004-knowledge-graph-ontology.md): ontology and relational + vocabulary contract. +- [ADR 0011](0011-prov-o-standard-relations.md): standard PROV-O relations. +- [ADR 0065](0065-prov-o-provenance-boundary.md): provenance authority + boundary. +- Issue #372: reconcile lowercase and repository-case public namespace IRIs. +- PR #349: authenticated Ontology Explorer consumer surface. + +## References — APA 7th + +GitHub. (2026). *Using custom workflows with GitHub Pages*. +https://docs.github.com/en/pages/getting-started-with-github-pages/using-custom-workflows-with-github-pages + +Sauermann, L., & Cyganiak, R. (2008). *Cool URIs for the Semantic Web*. +World Wide Web Consortium. https://www.w3.org/TR/cooluris/ + +Villazón-Terrazas, B., Vilches-Blázquez, L. M., Corcho, O., & Gómez-Pérez, A. +(2011). Methodological guidelines for publishing government linked data. In +D. Wood (Ed.), *Linking government data* (pp. 27–49). Springer. +https://doi.org/10.1007/978-1-4614-1767-5_2 diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index e65883463..ee22b0e6d 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -12,7 +12,7 @@ - **Multiple Project Extraction**: (Resolved) LLM prompt updated to request key_events as objects with project_name, separating events correctly. - **5W1H Missing**: (Resolved) LLM prompt updated to explicitly request 5W1H evidence items in the JSON output array. - **R&R and Keyman Missing**: (Resolved) LLM prompt updated to explicitly instruct using actual stated names rather than collective titles. -- **Entity Resolution / Searxng**: Abbreviations like "한전" and "한국전력" are not mapped properly using Searxng and KG corroboration. +- **Entity Resolution / Searxng**: Abbreviations like "한전" and "한국전력" are not mapped properly using Searxng and KG corroboration. - **Meso-level Team Mapping**: (Resolved) Checked extraction logic; `team` mapping logic is present and correct, but LLM needed better explicit instruction which is covered by R&R resolution. - **Base64 Image Omni-modal**: Current text-only embedding fails on images. Omni-modal LLM processing is required for images to capture layout, font size, colors, and spatial meaning. @@ -20,7 +20,15 @@ - **DB Architecture**: Ensure PostgreSQL is strictly used (no file DBs), 3rd normal form is maintained, and Hot Partitions are handled. DB locks must be managed (or use read/write replicas). - **Zotero Integration**: Papers and standards referenced by TEPP must be synced via Local Zotero API (http://localhost:23119/api/) and cited using APA 7th edition in docstrings. - **Testing**: We need actual testing of Psychometrics (Fast-MLSIRM parameter calibration, RMSE of estimates, Fixed-Item Parameter Calibration, CAT) against synthetic/demo data. -- **Security & Compliance**: PII masking cannot break the system. Need SOC 2 and CSAP compliance alternatives to blind PII masking. +- **Security & Compliance**: PII masking cannot break the system. Need SOC 2 and CSAP compliance alternatives to blind PII masking. - **LLM Orchestration**: Ensure ALL LLM calls route through `contextual-orchestrator` utilizing API keys (BYTEZ, NVIDIA, OPENROUTER, OPENAI) with auto model discovery and optimal reasoning effort allocation (Fugu/Conductor/TRINITY research). +## 4. Public Ontology Publication Gap +- **Observed gap**: `https://contextualwisdomlab.github.io/LineageWeave/ontology#` has no deployed public resource even though the authoritative OWL/RDFS/SKOS Turtle ontology already exists in `docs/ontology/lineageweave-kg.ttl`. +- **Active remediation — PR #371**: Add a deterministic GitHub Pages renderer, fail-closed publication boundary, and protected deployment workflow that publishes fragment-addressable HTML, byte-identical Turtle, isomorphic JSON-LD and N-Triples, the PROV-O support profile, and a source-digest manifest. +- **Publication safety**: The deployment path rejects duplicate term fragments, linked RDF IRIs outside HTTP(S), symlink outputs, source-overlapping output paths, and replacement of directories not marked as generated. Pull requests validate only; only `main` may publish, and an in-progress deployment is not cancelled by a newer run. +- **Namespace boundary**: The knowledge-graph ontology/runtime use a lowercase `lineageweave` namespace while the PROV-O support profile uses repository-case `LineageWeave`. PR #371 does not silently rewrite either semantic identity. Issue #372 owns the inventory, canonical namespace decision, compatibility vocabulary, deprecation window, stored-data migration, and downstream consumer verification. +- **Completion criteria**: Exact-head ontology/publication tests pass; owned renderer and publication-boundary statement/branch coverage is 100%; required security and repository Checks reach terminal success; an independent approval exists; the repository Pages source is GitHub Actions; the protected `main` deployment succeeds; and the requested URL resolves with stable anchors such as `#Post`. +- **Current truth**: Until PR #371 is merged and the `main` Pages deployment is verified, the URL remains unbuilt and must not be reported as live. + *This document is continuously updated by the hourly automated agent loop.* diff --git a/scripts/build_ontology_site.py b/scripts/build_ontology_site.py new file mode 100644 index 000000000..c2a03a4b1 --- /dev/null +++ b/scripts/build_ontology_site.py @@ -0,0 +1,457 @@ +#!/usr/bin/env python3 +"""Build the deterministic static LineageWeave ontology documentation site. + +The source Turtle ontology remains authoritative. This builder publishes a +human-readable, fragment-addressable HTML view plus equivalent JSON-LD and +N-Triples files without introducing a second ontology source of truth. +""" + +from __future__ import annotations + +import argparse +import hashlib +import html +import json +import shutil +from collections.abc import Iterable +from pathlib import Path +from typing import Any +from urllib.parse import quote + +from rdflib import Graph, Literal, URIRef +from rdflib.compare import to_canonical_graph +from rdflib.namespace import OWL, RDF, RDFS, SKOS + +PUBLIC_BASE_URL = "https://contextualwisdomlab.github.io/LineageWeave" +DOCUMENTATION_URL = f"{PUBLIC_BASE_URL}/ontology" +SOURCE_RELATIVE_PATH = Path("docs/ontology/lineageweave-kg.ttl") +PROV_PROFILE_RELATIVE_PATH = Path("docs/ontology/prov-o-support-profile.ttl") +TERM_TYPES: tuple[tuple[str, URIRef], ...] = ( + ("Classes", OWL.Class), + ("Object properties", OWL.ObjectProperty), + ("Datatype properties", OWL.DatatypeProperty), + ("Annotation properties", OWL.AnnotationProperty), + ("Concept schemes", SKOS.ConceptScheme), + ("Concepts", SKOS.Concept), +) +RELATION_FIELDS: tuple[tuple[str, URIRef], ...] = ( + ("Subclass of", RDFS.subClassOf), + ("Domain", RDFS.domain), + ("Range", RDFS.range), + ("Inverse of", OWL.inverseOf), + ("Broader", SKOS.broader), + ("Narrower", SKOS.narrower), + ("In scheme", SKOS.inScheme), +) + + +def _sha256(path: Path) -> str: + """Return a lowercase SHA-256 digest for one file.""" + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _fragment(value: URIRef) -> str: + """Return the stable local fragment used as the HTML anchor.""" + iri = str(value) + if "#" in iri: + return iri.rsplit("#", 1)[1] + return iri.rstrip("/").rsplit("/", 1)[-1] + + +def _preferred_literal(graph: Graph, subject: URIRef, predicate: URIRef) -> str | None: + """Choose an English, untagged, or first literal in a deterministic order.""" + literals = sorted( + (value for value in graph.objects(subject, predicate) if isinstance(value, Literal)), + key=lambda value: (value.language not in {"en", None}, value.language or "", str(value)), + ) + return str(literals[0]) if literals else None + + +def _canonicalize_json(value: Any, parent_key: str | None = None) -> Any: + """Canonicalize JSON-LD while preserving explicit ``@list`` ordering.""" + if isinstance(value, dict): + return {key: _canonicalize_json(value[key], key) for key in sorted(value)} + if isinstance(value, list): + canonical = [_canonicalize_json(item, parent_key) for item in value] + if parent_key == "@list": + return canonical + return sorted( + canonical, + key=lambda item: json.dumps( + item, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ), + ) + return value + + +def _write_serializations(graph: Graph, ontology_dir: Path) -> None: + """Write deterministic JSON-LD and line-sorted N-Triples serializations.""" + canonical_graph = to_canonical_graph(graph) + raw_jsonld = canonical_graph.serialize(format="json-ld", auto_compact=False) + parsed_jsonld = json.loads(raw_jsonld) + canonical_jsonld = _canonicalize_json(parsed_jsonld) + (ontology_dir / "ontology.jsonld").write_text( + json.dumps(canonical_jsonld, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + raw_nt = canonical_graph.serialize(format="nt") + nt_lines = sorted(line.strip() for line in raw_nt.splitlines() if line.strip()) + (ontology_dir / "ontology.nt").write_text( + "\n".join(nt_lines) + "\n", + encoding="utf-8", + ) + + +def _term_href(value: URIRef, ontology_subjects: set[URIRef]) -> str: + """Return a local fragment for local terms and an absolute IRI otherwise.""" + if value in ontology_subjects: + return f"#{quote(_fragment(value), safe='-._~')}" + return str(value) + + +def _render_link(value: URIRef, ontology_subjects: set[URIRef]) -> str: + """Render one safe HTML link for an ontology or external resource.""" + href = html.escape(_term_href(value, ontology_subjects), quote=True) + label = html.escape(_fragment(value) if value in ontology_subjects else str(value)) + external = "" if value in ontology_subjects else ' rel="external noreferrer"' + return f'{label}' + + +def _render_relation_rows( + graph: Graph, + subject: URIRef, + ontology_subjects: set[URIRef], +) -> str: + """Render standard semantic relations for one term.""" + rows: list[str] = [] + for heading, predicate in RELATION_FIELDS: + values = sorted( + (value for value in graph.objects(subject, predicate) if isinstance(value, URIRef)), + key=str, + ) + if not values: + continue + rendered = ", ".join(_render_link(value, ontology_subjects) for value in values) + rows.append(f"
{html.escape(heading)}
{rendered}
") + return "".join(rows) + + +def _render_term(graph: Graph, subject: URIRef, ontology_subjects: set[URIRef]) -> str: + """Render one fragment-addressable ontology term section.""" + fragment = _fragment(subject) + label = _preferred_literal(graph, subject, RDFS.label) or fragment + comment = _preferred_literal(graph, subject, RDFS.comment) + lookup_predicate = URIRef( + "https://contextualwisdomlab.github.io/lineageweave/ontology#lookupCode" + ) + lookup_codes = sorted(str(value) for value in graph.objects(subject, lookup_predicate)) + type_values = sorted( + (value for value in graph.objects(subject, RDF.type) if isinstance(value, URIRef)), + key=str, + ) + relation_rows = _render_relation_rows(graph, subject, ontology_subjects) + type_links = ", ".join(_render_link(value, ontology_subjects) for value in type_values) + lookup_html = "".join( + f"{html.escape(code)}" for code in lookup_codes + ) or "None" + comment_html = ( + f'

{html.escape(comment)}

' if comment else "" + ) + return ( + f'
' + f'

# ' + f"{html.escape(label)}

" + f'

{html.escape(str(subject))}

' + f"{comment_html}" + '
' + f"
RDF type
{type_links or 'Unspecified'}
" + f"
Lookup code
{lookup_html}
" + f"{relation_rows}" + "
" + "
" + ) + + +def _ontology_subjects(graph: Graph) -> set[URIRef]: + """Return every URI subject that belongs in the generated term inventory.""" + subjects: set[URIRef] = set() + for _, rdf_type in TERM_TYPES: + subjects.update( + subject + for subject in graph.subjects(RDF.type, rdf_type) + if isinstance(subject, URIRef) + ) + return subjects + + +def _render_term_sections(graph: Graph) -> tuple[str, str, int]: + """Render the navigation and categorized term sections.""" + subjects = _ontology_subjects(graph) + nav_items: list[str] = [] + sections: list[str] = [] + counted: set[URIRef] = set() + + for heading, rdf_type in TERM_TYPES: + terms = sorted( + ( + subject + for subject in graph.subjects(RDF.type, rdf_type) + if isinstance(subject, URIRef) + ), + key=lambda subject: ( + (_preferred_literal(graph, subject, RDFS.label) or _fragment(subject)).casefold(), + str(subject), + ), + ) + terms = [term for term in terms if term not in counted] + if not terms: + continue + section_id = heading.lower().replace(" ", "-") + nav_items.append( + f'
  • {html.escape(heading)} ' + f"{len(terms)}
  • " + ) + cards: list[str] = [] + for term in terms: + counted.add(term) + cards.append(_render_term(graph, term, subjects)) + sections.append( + f'
    ' + f"

    {html.escape(heading)}

    " + f'
    {"".join(cards)}
    ' + "
    " + ) + return "".join(nav_items), "".join(sections), len(counted) + + +def _ontology_metadata(graph: Graph) -> tuple[str, str, str]: + """Return ontology IRI, label, and comment from the source graph.""" + ontology_nodes = sorted( + ( + subject + for subject in graph.subjects(RDF.type, OWL.Ontology) + if isinstance(subject, URIRef) + ), + key=str, + ) + if not ontology_nodes: + raise ValueError("source graph does not declare an owl:Ontology resource") + subject = ontology_nodes[0] + label = _preferred_literal(graph, subject, RDFS.label) or "LineageWeave ontology" + comment = _preferred_literal(graph, subject, RDFS.comment) or ( + "Formal OWL 2, RDF Schema, and SKOS vocabulary for LineageWeave." + ) + return str(subject), label, comment + + +def _style_sheet() -> str: + """Return the self-contained accessible stylesheet.""" + return """ +:root { color-scheme: light dark; font-family: Inter, ui-sans-serif, system-ui, sans-serif; line-height: 1.55; } +* { box-sizing: border-box; } +body { margin: 0; color: #172033; background: #f5f7fb; } +a { color: #174ea6; } +a:focus-visible, button:focus-visible { outline: 3px solid #f2b705; outline-offset: 3px; } +header { color: white; background: #102a43; padding: 3rem max(1.25rem, calc((100vw - 78rem)/2)); } +header p { max-width: 70ch; color: #d9e8f5; } +header code { overflow-wrap: anywhere; } +main { max-width: 78rem; margin: 0 auto; padding: 2rem 1.25rem 4rem; } +.downloads, .summary-grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); } +.downloads a, .summary-card { display: block; padding: 1rem; border: 1px solid #c8d2df; border-radius: .75rem; background: #fff; } +.downloads a { text-decoration: none; font-weight: 700; } +.on-this-page { margin: 2rem 0; padding: 1rem 1.25rem; border-left: .35rem solid #2b6cb0; background: #eaf2fb; } +.on-this-page ul { display: flex; flex-wrap: wrap; gap: .65rem 1.25rem; list-style: none; padding: 0; } +.on-this-page span { font-variant-numeric: tabular-nums; } +.term-section { scroll-margin-top: 1rem; margin-top: 3rem; } +.term-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 25rem), 1fr)); gap: 1rem; } +.term-card { scroll-margin-top: 1rem; padding: 1.15rem; border: 1px solid #c8d2df; border-radius: .75rem; background: #fff; box-shadow: 0 1px 2px rgb(16 42 67 / 8%); } +.term-card h3 { margin-top: 0; } +.fragment-link { text-decoration: none; opacity: .55; } +.iri { overflow-wrap: anywhere; } +.term-comment { white-space: pre-wrap; } +.term-facts { display: grid; grid-template-columns: minmax(7rem, max-content) 1fr; gap: .35rem .75rem; } +.term-facts dt { font-weight: 700; } +.term-facts dd { margin: 0; overflow-wrap: anywhere; } +.term-facts code + code { margin-left: .35rem; } +.notice { padding: 1rem; border-radius: .75rem; background: #fff7d6; border: 1px solid #e6c75b; } +footer { border-top: 1px solid #c8d2df; padding: 2rem 1.25rem; text-align: center; } +@media (prefers-color-scheme: dark) { + body { color: #e8eef5; background: #0b1522; } + header { background: #06111d; } + a { color: #8fc2ff; } + .downloads a, .summary-card, .term-card { background: #122236; border-color: #38516a; } + .on-this-page { background: #102a43; } + .notice { background: #3d3212; border-color: #8a712b; } + footer { border-color: #38516a; } +} +@media print { + body { background: white; color: black; } + header { background: white; color: black; padding: 1rem 0; } + header p { color: black; } + main { max-width: none; padding: 0; } + .term-card { break-inside: avoid; box-shadow: none; } +} +""".strip() + + +def _render_ontology_page(graph: Graph, source_sha256: str) -> tuple[str, int]: + """Render the complete ontology documentation page and unique term count.""" + ontology_iri, label, comment = _ontology_metadata(graph) + nav, term_sections, term_count = _render_term_sections(graph) + return ( + "\n" + '\n\n' + '\n' + '\n' + f"{html.escape(label)}\n" + f'\n' + f'\n' + '\n' + '\n' + '\n' + f"\n" + "\n\n" + "
    " + '

    LineageWeave / Ontology

    ' + f"

    {html.escape(label)}

    " + f"

    {html.escape(comment)}

    " + f'

    Ontology IRI: {html.escape(ontology_iri)}

    ' + "
    " + "
    " + '
    ' + '

    Machine-readable artifacts

    ' + '
    " + '
    ' + f'
    {term_count}
    Unique documented terms
    ' + f'
    {len(graph)}
    RDF triples
    ' + f'
    {html.escape(source_sha256[:12])}
    Source SHA-256 prefix
    ' + "
    " + '

    Identity boundary: this project page is the stable documentation endpoint requested for the repository. The source ontology IRI shown above remains the semantic identifier until an explicit versioned namespace-migration ADR says otherwise.

    ' + '" + f"{term_sections}" + "
    " + '' + "\n\n", + term_count, + ) + + +def _render_root_page() -> str: + """Render the project Pages landing page with a direct ontology action.""" + return ( + "\n" + '' + '' + "LineageWeave public specifications" + f'' + f"" + "

    LineageWeave public specifications

    " + "

    Stable, machine-readable public artifacts published from the protected repository source.

    " + '

    Ontology

    Inspect the OWL 2, RDF Schema, SKOS, and provenance vocabulary.

    ' + '

    Open the ontology documentation

    ' + "" + "\n" + ) + + +def _write_manifest( + ontology_dir: Path, + source: Path, + graph: Graph, + term_count: int, +) -> None: + """Write deterministic provenance metadata for the published ontology.""" + payload = { + "documentation_url": DOCUMENTATION_URL, + "generated_artifacts": ["index.html", "ontology.jsonld", "ontology.nt"], + "ontology_triple_count": len(graph), + "ontology_unique_term_count": term_count, + "source_path": SOURCE_RELATIVE_PATH.as_posix(), + "source_sha256": _sha256(source), + } + (ontology_dir / "manifest.json").write_text( + json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +def build_site(repository_root: Path, output_dir: Path) -> None: + """Build the complete static ontology site under ``output_dir``.""" + root = repository_root.resolve() + output = output_dir.resolve() + source = root / SOURCE_RELATIVE_PATH + prov_profile = root / PROV_PROFILE_RELATIVE_PATH + if not source.is_file(): + raise FileNotFoundError(f"ontology source is missing: {source}") + if not prov_profile.is_file(): + raise FileNotFoundError(f"PROV-O support profile is missing: {prov_profile}") + + if output.exists(): + shutil.rmtree(output) + ontology_dir = output / "ontology" + ontology_dir.mkdir(parents=True) + + graph = Graph().parse(source, format="turtle") + source_sha256 = _sha256(source) + ontology_html, term_count = _render_ontology_page(graph, source_sha256) + + (output / ".nojekyll").write_text("", encoding="utf-8") + (output / "index.html").write_text(_render_root_page(), encoding="utf-8") + (ontology_dir / "index.html").write_text(ontology_html, encoding="utf-8") + shutil.copyfile(source, ontology_dir / "ontology.ttl") + shutil.copyfile(prov_profile, ontology_dir / "prov-o-support-profile.ttl") + _write_serializations(graph, ontology_dir) + _write_manifest(ontology_dir, source, graph, term_count) + (output / "robots.txt").write_text( + "User-agent: *\nAllow: /\nSitemap: " f"{PUBLIC_BASE_URL}/sitemap.xml\n", + encoding="utf-8", + ) + (output / "sitemap.xml").write_text( + '\n' + '\n' + f" {PUBLIC_BASE_URL}/\n" + f" {DOCUMENTATION_URL}\n" + "\n", + encoding="utf-8", + ) + + +def _parse_args(argv: Iterable[str] | None = None) -> argparse.Namespace: + """Parse command-line arguments for repository and output locations.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--repository-root", + type=Path, + default=Path(__file__).resolve().parents[1], + help="LineageWeave repository root (default: inferred from this script)", + ) + parser.add_argument( + "--output-dir", + type=Path, + default=Path("_site"), + help="Static site output directory (default: _site)", + ) + return parser.parse_args(argv) + + +def main(argv: Iterable[str] | None = None) -> int: + """Build the site from CLI arguments and return a process exit code.""" + args = _parse_args(argv) + build_site(args.repository_root, args.output_dir) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/publish_ontology_site.py b/scripts/publish_ontology_site.py new file mode 100644 index 000000000..fee47d3d3 --- /dev/null +++ b/scripts/publish_ontology_site.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Validate and publish the deterministic LineageWeave ontology Pages site. + +This safety wrapper keeps the renderer focused on presentation while enforcing +fail-closed graph and filesystem boundaries before the renderer may replace an +output directory or emit links derived from ontology IRIs. +""" + +from __future__ import annotations + +import argparse +import importlib.util +from collections.abc import Iterable +from pathlib import Path +from types import ModuleType +from urllib.parse import urlsplit + +from rdflib import Graph, URIRef +from rdflib.namespace import OWL, RDF, RDFS, SKOS + +OUTPUT_MARKER = ".lineageweave-ontology-site" +SOURCE_RELATIVE_PATH = Path("docs/ontology/lineageweave-kg.ttl") +PROV_PROFILE_RELATIVE_PATH = Path("docs/ontology/prov-o-support-profile.ttl") +TERM_TYPES: tuple[URIRef, ...] = ( + OWL.Class, + OWL.ObjectProperty, + OWL.DatatypeProperty, + OWL.AnnotationProperty, + SKOS.ConceptScheme, + SKOS.Concept, +) +LINK_PREDICATES: tuple[URIRef, ...] = ( + RDF.type, + RDFS.subClassOf, + RDFS.domain, + RDFS.range, + OWL.inverseOf, + SKOS.broader, + SKOS.narrower, + SKOS.inScheme, +) + + +def _load_renderer(repository_root: Path) -> ModuleType: + """Load the sibling deterministic renderer from one repository root.""" + script = repository_root / "scripts" / "build_ontology_site.py" + spec = importlib.util.spec_from_file_location("lineageweave_ontology_renderer", script) + if spec is None or spec.loader is None: + raise RuntimeError(f"ontology renderer could not be loaded: {script}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _fragment(value: URIRef) -> str: + """Return the local fragment used by the renderer as an HTML identifier.""" + iri = str(value) + if "#" in iri: + return iri.rsplit("#", 1)[1] + return iri.rstrip("/").rsplit("/", 1)[-1] + + +def _public_subjects(graph: Graph) -> set[URIRef]: + """Return URI subjects included in the public HTML term inventory.""" + return { + subject + for term_type in TERM_TYPES + for subject in graph.subjects(RDF.type, term_type) + if isinstance(subject, URIRef) + } + + +def validate_public_graph(graph: Graph) -> None: + """Reject RDF structures that cannot be rendered safely and uniquely.""" + subjects = _public_subjects(graph) + fragment_owner: dict[str, URIRef] = {} + for subject in sorted(subjects, key=str): + fragment = _fragment(subject) + owner = fragment_owner.setdefault(fragment, subject) + if owner != subject: + raise ValueError( + f"duplicate ontology fragment {fragment!r}: {owner} and {subject}" + ) + + for subject in subjects: + for predicate in LINK_PREDICATES: + for value in graph.objects(subject, predicate): + if not isinstance(value, URIRef) or value in subjects: + continue + scheme = urlsplit(str(value)).scheme.lower() + if scheme not in {"http", "https"}: + raise ValueError( + f"unsafe linked IRI scheme {scheme!r} for {value}" + ) + + +def _validate_output_directory(output_dir: Path, source: Path, profile: Path) -> Path: + """Resolve an output path and ensure replacement cannot delete source data.""" + requested = output_dir.expanduser() + if requested.is_symlink(): + raise ValueError("output directory must not be a symbolic link") + output = requested.resolve() + if source.is_relative_to(output) or profile.is_relative_to(output): + raise ValueError("output directory overlaps ontology source files") + if output.exists() and not (output / OUTPUT_MARKER).is_file(): + raise ValueError("refusing to replace an unmarked output directory") + return output + + +def publish_site(repository_root: Path, output_dir: Path) -> None: + """Validate sources and publish one safely replaceable static site tree.""" + root = repository_root.resolve() + source = root / SOURCE_RELATIVE_PATH + profile = root / PROV_PROFILE_RELATIVE_PATH + if not source.is_file(): + raise FileNotFoundError(f"ontology source is missing: {source}") + if not profile.is_file(): + raise FileNotFoundError(f"PROV-O support profile is missing: {profile}") + + output = _validate_output_directory(output_dir, source, profile) + graph = Graph().parse(source, format="turtle") + validate_public_graph(graph) + + renderer = _load_renderer(root) + renderer.build_site(root, output) + (output / OUTPUT_MARKER).write_text("", encoding="utf-8") + + +def _parse_args(argv: Iterable[str] | None = None) -> argparse.Namespace: + """Parse repository and output paths for the publication command.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--repository-root", + type=Path, + default=Path(__file__).resolve().parents[1], + help="LineageWeave repository root", + ) + parser.add_argument( + "--output-dir", + type=Path, + default=Path("_site"), + help="Static site output directory", + ) + return parser.parse_args(argv) + + +def main(argv: Iterable[str] | None = None) -> int: + """Publish the site from CLI arguments and return a process exit code.""" + args = _parse_args(argv) + publish_site(args.repository_root, args.output_dir) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_ontology_site.py b/tests/test_ontology_site.py new file mode 100644 index 000000000..e162faea5 --- /dev/null +++ b/tests/test_ontology_site.py @@ -0,0 +1,211 @@ +"""Contract tests for the deterministic LineageWeave ontology Pages site.""" + +from __future__ import annotations + +import hashlib +import importlib.util +import json +from pathlib import Path + +from rdflib import Graph +from rdflib.compare import isomorphic + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "build_ontology_site.py" + + +def _load_builder(): + spec = importlib.util.spec_from_file_location("build_ontology_site", SCRIPT) + if spec is None or spec.loader is None: + raise AssertionError("ontology site builder could not be loaded") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _tree_hashes(root: Path) -> dict[str, str]: + return { + str(path.relative_to(root)): hashlib.sha256(path.read_bytes()).hexdigest() + for path in sorted(root.rglob("*")) + if path.is_file() + } + + +def test_build_publishes_dereferenceable_html_and_machine_formats(tmp_path: Path) -> None: + builder = _load_builder() + output = tmp_path / "site" + + builder.build_site(ROOT, output) + + ontology_dir = output / "ontology" + assert (output / ".nojekyll").is_file() + assert (output / "index.html").is_file() + assert (ontology_dir / "index.html").is_file() + assert (ontology_dir / "ontology.ttl").read_bytes() == ( + ROOT / "docs" / "ontology" / "lineageweave-kg.ttl" + ).read_bytes() + assert (ontology_dir / "prov-o-support-profile.ttl").is_file() + + html = (ontology_dir / "index.html").read_text(encoding="utf-8") + assert '' in html + assert 'id="Post"' in html + assert 'href="#Post"' in html + assert "LineageWeave Knowledge Graph Ontology" in html + assert "ontology.ttl" in html + assert "ontology.jsonld" in html + assert "ontology.nt" in html + + +def test_render_term_escapes_untrusted_ontology_text() -> None: + builder = _load_builder() + graph = Graph() + term = builder.URIRef("https://example.test/ontology#Unsafe") + graph.add((term, builder.RDF.type, builder.OWL.Class)) + graph.add( + (term, builder.RDFS.label, builder.Literal("")) + ) + graph.add( + (term, builder.RDFS.comment, builder.Literal("A & evidence.")) + ) + + rendered = builder._render_term(graph, term, {term}) + + assert "" not in rendered + assert "<script>alert(1)</script>" in rendered + assert "A <source> & evidence." in rendered + + +def test_serializations_round_trip_to_the_source_graph(tmp_path: Path) -> None: + builder = _load_builder() + output = tmp_path / "site" + builder.build_site(ROOT, output) + + source = Graph().parse(ROOT / "docs" / "ontology" / "lineageweave-kg.ttl", format="turtle") + jsonld = Graph().parse(output / "ontology" / "ontology.jsonld", format="json-ld") + ntriples = Graph().parse(output / "ontology" / "ontology.nt", format="nt") + + assert isomorphic(source, jsonld) + assert isomorphic(source, ntriples) + + +def test_build_is_byte_deterministic(tmp_path: Path) -> None: + builder = _load_builder() + first = tmp_path / "first" + second = tmp_path / "second" + + builder.build_site(ROOT, first) + builder.build_site(ROOT, second) + + assert _tree_hashes(first) == _tree_hashes(second) + + +def test_metadata_manifest_has_source_digest_and_no_build_clock(tmp_path: Path) -> None: + builder = _load_builder() + output = tmp_path / "site" + builder.build_site(ROOT, output) + + manifest = json.loads((output / "ontology" / "manifest.json").read_text(encoding="utf-8")) + source = ROOT / "docs" / "ontology" / "lineageweave-kg.ttl" + assert manifest["source_sha256"] == hashlib.sha256(source.read_bytes()).hexdigest() + assert "built_at" not in manifest + assert manifest["documentation_url"] == "https://contextualwisdomlab.github.io/LineageWeave/ontology" + + +def test_helpers_cover_slash_fragments_json_lists_and_missing_ontology() -> None: + builder = _load_builder() + assert builder._fragment(builder.URIRef("https://example.test/vocabulary/Term")) == "Term" + assert builder._canonicalize_json({"@list": ["b", "a"]}) == {"@list": ["b", "a"]} + graph = Graph() + graph.add( + ( + builder.URIRef("https://example.test/ontology#Term"), + builder.RDF.type, + builder.OWL.Class, + ) + ) + nav, sections, term_count = builder._render_term_sections(graph) + assert 'href="#classes"' in nav + assert 'id="object-properties"' not in sections + assert term_count == 1 + try: + builder._ontology_metadata(Graph()) + except ValueError as exc: + assert "owl:Ontology" in str(exc) + else: + raise AssertionError("missing owl:Ontology declaration was accepted") + + +def test_render_term_sections_keeps_one_anchor_for_multi_typed_terms() -> None: + builder = _load_builder() + graph = Graph() + term = builder.URIRef("https://example.test/ontology#SharedTerm") + graph.add((term, builder.RDF.type, builder.OWL.Class)) + graph.add((term, builder.RDF.type, builder.SKOS.Concept)) + + nav, sections, term_count = builder._render_term_sections(graph) + + assert nav.count("SharedTerm") == 0 + assert sections.count('id="SharedTerm"') == 1 + assert term_count == 1 + + +def test_builder_fails_closed_for_missing_sources_and_replaces_output(tmp_path: Path) -> None: + builder = _load_builder() + repository = tmp_path / "repository" + output = tmp_path / "site" + output.mkdir() + (output / "stale.txt").write_text("stale", encoding="utf-8") + + try: + builder.build_site(repository, output) + except FileNotFoundError as exc: + assert "ontology source" in str(exc) + else: + raise AssertionError("missing ontology source was accepted") + + ontology_dir = repository / "docs" / "ontology" + ontology_dir.mkdir(parents=True) + (ontology_dir / "lineageweave-kg.ttl").write_text( + (ROOT / "docs" / "ontology" / "lineageweave-kg.ttl").read_text(encoding="utf-8"), + encoding="utf-8", + ) + try: + builder.build_site(repository, output) + except FileNotFoundError as exc: + assert "PROV-O support profile" in str(exc) + else: + raise AssertionError("missing PROV-O profile was accepted") + + (ontology_dir / "prov-o-support-profile.ttl").write_text("", encoding="utf-8") + builder.build_site(repository, output) + assert not (output / "stale.txt").exists() + + +def test_cli_main_and_module_entrypoint(tmp_path: Path, monkeypatch) -> None: + builder = _load_builder() + output = tmp_path / "direct" + assert builder.main(["--repository-root", str(ROOT), "--output-dir", str(output)]) == 0 + assert (output / "ontology" / "index.html").is_file() + + import runpy + import sys + + entry_output = tmp_path / "entry" + monkeypatch.setattr( + sys, + "argv", + [ + str(SCRIPT), + "--repository-root", + str(ROOT), + "--output-dir", + str(entry_output), + ], + ) + try: + runpy.run_path(str(SCRIPT), run_name="__main__") + except SystemExit as exc: + assert exc.code == 0 + else: + raise AssertionError("module entrypoint did not exit") + assert (entry_output / "ontology" / "manifest.json").is_file() diff --git a/tests/test_publish_ontology_site.py b/tests/test_publish_ontology_site.py new file mode 100644 index 000000000..bec404ac2 --- /dev/null +++ b/tests/test_publish_ontology_site.py @@ -0,0 +1,185 @@ +"""Security and deployment-boundary tests for ontology Pages publication.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest +from rdflib import Graph, URIRef +from rdflib.namespace import OWL, RDF, RDFS + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "publish_ontology_site.py" + + +def _load_publisher(): + spec = importlib.util.spec_from_file_location("publish_ontology_site", SCRIPT) + if spec is None or spec.loader is None: + raise AssertionError("ontology publisher could not be loaded") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _repository_fixture(tmp_path: Path) -> Path: + repository = tmp_path / "repository" + ontology_dir = repository / "docs" / "ontology" + scripts_dir = repository / "scripts" + ontology_dir.mkdir(parents=True) + scripts_dir.mkdir(parents=True) + for name in ("lineageweave-kg.ttl", "prov-o-support-profile.ttl"): + (ontology_dir / name).write_bytes((ROOT / "docs" / "ontology" / name).read_bytes()) + (scripts_dir / "build_ontology_site.py").write_bytes( + (ROOT / "scripts" / "build_ontology_site.py").read_bytes() + ) + return repository + + +def test_publication_refuses_unmarked_existing_output(tmp_path: Path) -> None: + publisher = _load_publisher() + repository = _repository_fixture(tmp_path) + output = tmp_path / "site" + output.mkdir() + (output / "unrelated.txt").write_text("do not delete", encoding="utf-8") + + with pytest.raises(ValueError, match="unmarked"): + publisher.publish_site(repository, output) + + assert (output / "unrelated.txt").read_text(encoding="utf-8") == "do not delete" + + +def test_publication_replaces_only_marked_output_and_writes_marker(tmp_path: Path) -> None: + publisher = _load_publisher() + repository = _repository_fixture(tmp_path) + output = tmp_path / "site" + + publisher.publish_site(repository, output) + (output / "stale.txt").write_text("stale", encoding="utf-8") + publisher.publish_site(repository, output) + + assert (output / publisher.OUTPUT_MARKER).is_file() + assert not (output / "stale.txt").exists() + assert (output / "ontology" / "index.html").is_file() + + +def test_publication_rejects_symlink_and_source_overlapping_outputs(tmp_path: Path) -> None: + publisher = _load_publisher() + repository = _repository_fixture(tmp_path) + target = tmp_path / "target" + target.mkdir() + symlink = tmp_path / "site-link" + symlink.symlink_to(target, target_is_directory=True) + + with pytest.raises(ValueError, match="symbolic link"): + publisher.publish_site(repository, symlink) + with pytest.raises(ValueError, match="overlaps"): + publisher.publish_site(repository, repository) + + +def test_graph_validation_rejects_duplicate_fragments_and_unsafe_links() -> None: + publisher = _load_publisher() + duplicate = Graph() + first = URIRef("https://one.example/ontology#Shared") + second = URIRef("https://two.example/ontology#Shared") + duplicate.add((first, RDF.type, OWL.Class)) + duplicate.add((second, RDF.type, OWL.Class)) + + with pytest.raises(ValueError, match="duplicate ontology fragment"): + publisher.validate_public_graph(duplicate) + + unsafe = Graph() + subject = URIRef("https://example.test/ontology#Subject") + unsafe.add((subject, RDF.type, OWL.Class)) + unsafe.add((subject, RDFS.subClassOf, URIRef("javascript:alert(1)"))) + with pytest.raises(ValueError, match="unsafe linked IRI scheme"): + publisher.validate_public_graph(unsafe) + + +def test_graph_validation_allows_http_relations_and_multiple_term_types() -> None: + publisher = _load_publisher() + graph = Graph() + subject = URIRef("https://example.test/ontology#Subject") + graph.add((subject, RDF.type, OWL.Class)) + graph.add((subject, RDF.type, OWL.AnnotationProperty)) + graph.add((subject, RDFS.subClassOf, URIRef("https://external.example/Parent"))) + + publisher.validate_public_graph(graph) + + +def test_main_publishes_site(tmp_path: Path) -> None: + publisher = _load_publisher() + repository = _repository_fixture(tmp_path) + output = tmp_path / "site" + + assert publisher.main([ + "--repository-root", + str(repository), + "--output-dir", + str(output), + ]) == 0 + assert (output / "ontology" / "manifest.json").is_file() + + +def test_loader_and_fragment_failure_branches(tmp_path: Path, monkeypatch) -> None: + publisher = _load_publisher() + assert publisher._fragment(URIRef("https://example.test/vocabulary/Term")) == "Term" + monkeypatch.setattr(publisher.importlib.util, "spec_from_file_location", lambda *_args: None) + with pytest.raises(RuntimeError, match="could not be loaded"): + publisher._load_renderer(tmp_path) + + +def test_graph_validation_ignores_non_uri_and_local_link_objects() -> None: + publisher = _load_publisher() + from rdflib import BNode, Literal + + graph = Graph() + subject = URIRef("https://example.test/ontology#Subject") + local_parent = URIRef("https://example.test/ontology#Parent") + graph.add((subject, RDF.type, OWL.Class)) + graph.add((local_parent, RDF.type, OWL.Class)) + graph.add((BNode(), RDF.type, OWL.Class)) + graph.add((subject, RDFS.subClassOf, local_parent)) + graph.add((subject, RDFS.domain, Literal("not a link"))) + + publisher.validate_public_graph(graph) + + +def test_publication_fails_closed_for_missing_sources(tmp_path: Path) -> None: + publisher = _load_publisher() + repository = tmp_path / "repository" + output = tmp_path / "site" + + with pytest.raises(FileNotFoundError, match="ontology source"): + publisher.publish_site(repository, output) + + ontology_dir = repository / "docs" / "ontology" + ontology_dir.mkdir(parents=True) + (ontology_dir / "lineageweave-kg.ttl").write_bytes( + (ROOT / "docs" / "ontology" / "lineageweave-kg.ttl").read_bytes() + ) + with pytest.raises(FileNotFoundError, match="PROV-O support profile"): + publisher.publish_site(repository, output) + + +def test_module_entrypoint(tmp_path: Path, monkeypatch) -> None: + import runpy + import sys + + repository = _repository_fixture(tmp_path) + output = tmp_path / "entry-site" + monkeypatch.setattr( + sys, + "argv", + [ + str(SCRIPT), + "--repository-root", + str(repository), + "--output-dir", + str(output), + ], + ) + with pytest.raises(SystemExit) as exc_info: + runpy.run_path(str(SCRIPT), run_name="__main__") + assert exc_info.value.code == 0 + assert (output / "ontology" / "index.html").is_file() From b04f2d2db9b992d1be675b52587f872c330ab161 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 04:59:37 -0700 Subject: [PATCH 02/22] docs: point ontology remediation at clean PR --- docs/product-technical-gap-baseline.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index ee22b0e6d..daa7e8391 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -25,10 +25,10 @@ ## 4. Public Ontology Publication Gap - **Observed gap**: `https://contextualwisdomlab.github.io/LineageWeave/ontology#` has no deployed public resource even though the authoritative OWL/RDFS/SKOS Turtle ontology already exists in `docs/ontology/lineageweave-kg.ttl`. -- **Active remediation — PR #371**: Add a deterministic GitHub Pages renderer, fail-closed publication boundary, and protected deployment workflow that publishes fragment-addressable HTML, byte-identical Turtle, isomorphic JSON-LD and N-Triples, the PROV-O support profile, and a source-digest manifest. +- **Active remediation — PR #373**: Add a deterministic GitHub Pages renderer, fail-closed publication boundary, and protected deployment workflow that publishes fragment-addressable HTML, byte-identical Turtle, isomorphic JSON-LD and N-Triples, the PROV-O support profile, and a source-digest manifest. PR #373 supersedes the contaminated predecessor PR #371 with a clean one-commit, eight-file diff. - **Publication safety**: The deployment path rejects duplicate term fragments, linked RDF IRIs outside HTTP(S), symlink outputs, source-overlapping output paths, and replacement of directories not marked as generated. Pull requests validate only; only `main` may publish, and an in-progress deployment is not cancelled by a newer run. -- **Namespace boundary**: The knowledge-graph ontology/runtime use a lowercase `lineageweave` namespace while the PROV-O support profile uses repository-case `LineageWeave`. PR #371 does not silently rewrite either semantic identity. Issue #372 owns the inventory, canonical namespace decision, compatibility vocabulary, deprecation window, stored-data migration, and downstream consumer verification. +- **Namespace boundary**: The knowledge-graph ontology/runtime use a lowercase `lineageweave` namespace while the PROV-O support profile uses repository-case `LineageWeave`. PR #373 does not silently rewrite either semantic identity. Issue #372 owns the inventory, canonical namespace decision, compatibility vocabulary, deprecation window, stored-data migration, and downstream consumer verification. - **Completion criteria**: Exact-head ontology/publication tests pass; owned renderer and publication-boundary statement/branch coverage is 100%; required security and repository Checks reach terminal success; an independent approval exists; the repository Pages source is GitHub Actions; the protected `main` deployment succeeds; and the requested URL resolves with stable anchors such as `#Post`. -- **Current truth**: Until PR #371 is merged and the `main` Pages deployment is verified, the URL remains unbuilt and must not be reported as live. +- **Current truth**: Until PR #373 is merged and the `main` Pages deployment is verified, the URL remains unbuilt and must not be reported as live. *This document is continuously updated by the hourly automated agent loop.* From e215429f86d8180113f256dacb5e1ac1a397bc11 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 05:03:08 -0700 Subject: [PATCH 03/22] test: prefer English ontology labels over untagged literals --- tests/test_ontology_site.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_ontology_site.py b/tests/test_ontology_site.py index e162faea5..2b9f1d8c3 100644 --- a/tests/test_ontology_site.py +++ b/tests/test_ontology_site.py @@ -75,6 +75,17 @@ def test_render_term_escapes_untrusted_ontology_text() -> None: assert "A <source> & evidence." in rendered +def test_preferred_literal_uses_english_before_untagged_and_other_languages() -> None: + builder = _load_builder() + graph = Graph() + term = builder.URIRef("https://example.test/ontology#Localized") + graph.add((term, builder.RDFS.label, builder.Literal("untagged"))) + graph.add((term, builder.RDFS.label, builder.Literal("English", lang="en"))) + graph.add((term, builder.RDFS.label, builder.Literal("한국어", lang="ko"))) + + assert builder._preferred_literal(graph, term, builder.RDFS.label) == "English" + + def test_serializations_round_trip_to_the_source_graph(tmp_path: Path) -> None: builder = _load_builder() output = tmp_path / "site" From be996ec6da5e557dfeca08fe2bd9efffd6158b7a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 05:09:09 -0700 Subject: [PATCH 04/22] fix: prefer English ontology labels deterministically --- scripts/build_ontology_site.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/build_ontology_site.py b/scripts/build_ontology_site.py index c2a03a4b1..cc362c056 100644 --- a/scripts/build_ontology_site.py +++ b/scripts/build_ontology_site.py @@ -62,7 +62,11 @@ def _preferred_literal(graph: Graph, subject: URIRef, predicate: URIRef) -> str """Choose an English, untagged, or first literal in a deterministic order.""" literals = sorted( (value for value in graph.objects(subject, predicate) if isinstance(value, Literal)), - key=lambda value: (value.language not in {"en", None}, value.language or "", str(value)), + key=lambda value: ( + 0 if value.language == "en" else 1 if value.language is None else 2, + value.language or "", + str(value), + ), ) return str(literals[0]) if literals else None @@ -223,7 +227,7 @@ def _render_term_sections(graph: Graph) -> tuple[str, str, int]: sections.append( f'
    ' f"

    {html.escape(heading)}

    " - f'
    {"".join(cards)}
    ' + f'
    ' "
    " ) return "".join(nav_items), "".join(sections), len(counted) From 8c524d67c84e62868944fd94624339c387a28363 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 05:11:12 -0700 Subject: [PATCH 05/22] test: render SKOS preferred labels in ontology headings --- tests/test_ontology_site.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_ontology_site.py b/tests/test_ontology_site.py index 2b9f1d8c3..e3638cbe5 100644 --- a/tests/test_ontology_site.py +++ b/tests/test_ontology_site.py @@ -86,6 +86,19 @@ def test_preferred_literal_uses_english_before_untagged_and_other_languages() -> assert builder._preferred_literal(graph, term, builder.RDFS.label) == "English" +def test_render_term_uses_skos_preferred_label_without_rdfs_label() -> None: + builder = _load_builder() + graph = Graph() + term = builder.URIRef("https://example.test/ontology#RawFragment") + graph.add((term, builder.RDF.type, builder.SKOS.Concept)) + graph.add((term, builder.SKOS.prefLabel, builder.Literal("Human label", lang="en"))) + + rendered = builder._render_term(graph, term, {term}) + + assert ">Human label" in rendered + assert 'aria-label="Link to Human label"' in rendered + + def test_serializations_round_trip_to_the_source_graph(tmp_path: Path) -> None: builder = _load_builder() output = tmp_path / "site" From 38cd956171e811c111d98dc4530262716edd179c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 05:12:55 -0700 Subject: [PATCH 06/22] fix: render SKOS preferred labels in ontology headings --- scripts/build_ontology_site.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/scripts/build_ontology_site.py b/scripts/build_ontology_site.py index cc362c056..3a2280b89 100644 --- a/scripts/build_ontology_site.py +++ b/scripts/build_ontology_site.py @@ -147,7 +147,11 @@ def _render_relation_rows( def _render_term(graph: Graph, subject: URIRef, ontology_subjects: set[URIRef]) -> str: """Render one fragment-addressable ontology term section.""" fragment = _fragment(subject) - label = _preferred_literal(graph, subject, RDFS.label) or fragment + label = ( + _preferred_literal(graph, subject, RDFS.label) + or _preferred_literal(graph, subject, SKOS.prefLabel) + or fragment + ) comment = _preferred_literal(graph, subject, RDFS.comment) lookup_predicate = URIRef( "https://contextualwisdomlab.github.io/lineageweave/ontology#lookupCode" @@ -208,7 +212,11 @@ def _render_term_sections(graph: Graph) -> tuple[str, str, int]: if isinstance(subject, URIRef) ), key=lambda subject: ( - (_preferred_literal(graph, subject, RDFS.label) or _fragment(subject)).casefold(), + ( + _preferred_literal(graph, subject, RDFS.label) + or _preferred_literal(graph, subject, SKOS.prefLabel) + or _fragment(subject) + ).casefold(), str(subject), ), ) From bc91481dac7350975de7ec00f11d4e54f676eb2c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 05:15:42 -0700 Subject: [PATCH 07/22] fix: restore valid ontology term grid markup --- scripts/build_ontology_site.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/build_ontology_site.py b/scripts/build_ontology_site.py index 3a2280b89..423aff2c6 100644 --- a/scripts/build_ontology_site.py +++ b/scripts/build_ontology_site.py @@ -235,7 +235,7 @@ def _render_term_sections(graph: Graph) -> tuple[str, str, int]: sections.append( f'
    ' f"

    {html.escape(heading)}

    " - f'
    ' + '
    ' + "".join(cards) + "
    " "
    " ) return "".join(nav_items), "".join(sections), len(counted) From d14dc49025886a00251a3f579f4e9d53ed55f0ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 22:17:04 +0900 Subject: [PATCH 08/22] fix: harden ontology site identifiers and output safety --- scripts/build_ontology_site.py | 20 ++++++++++++++------ scripts/ontology_site_contract.py | 8 ++++++++ scripts/publish_ontology_site.py | 10 +++++++++- tests/test_ontology_site.py | 28 +++++++++++++++++++++++++--- tests/test_publish_ontology_site.py | 3 +++ 5 files changed, 59 insertions(+), 10 deletions(-) create mode 100644 scripts/ontology_site_contract.py diff --git a/scripts/build_ontology_site.py b/scripts/build_ontology_site.py index 423aff2c6..16077c117 100644 --- a/scripts/build_ontology_site.py +++ b/scripts/build_ontology_site.py @@ -16,7 +16,11 @@ from collections.abc import Iterable from pathlib import Path from typing import Any -from urllib.parse import quote + +try: + from scripts.ontology_site_contract import public_fragment +except ModuleNotFoundError: # direct execution with ``scripts`` as sys.path[0] + from ontology_site_contract import public_fragment from rdflib import Graph, Literal, URIRef from rdflib.compare import to_canonical_graph @@ -113,7 +117,7 @@ def _write_serializations(graph: Graph, ontology_dir: Path) -> None: def _term_href(value: URIRef, ontology_subjects: set[URIRef]) -> str: """Return a local fragment for local terms and an absolute IRI otherwise.""" if value in ontology_subjects: - return f"#{quote(_fragment(value), safe='-._~')}" + return f"#{public_fragment(_fragment(value))}" return str(value) @@ -146,11 +150,12 @@ def _render_relation_rows( def _render_term(graph: Graph, subject: URIRef, ontology_subjects: set[URIRef]) -> str: """Render one fragment-addressable ontology term section.""" - fragment = _fragment(subject) + raw_fragment = _fragment(subject) + fragment = public_fragment(raw_fragment) label = ( _preferred_literal(graph, subject, RDFS.label) or _preferred_literal(graph, subject, SKOS.prefLabel) - or fragment + or raw_fragment ) comment = _preferred_literal(graph, subject, RDFS.comment) lookup_predicate = URIRef( @@ -171,7 +176,7 @@ def _render_term(graph: Graph, subject: URIRef, ontology_subjects: set[URIRef]) ) return ( f'
    ' - f'

    # ' f"{html.escape(label)}

    " f'

    {html.escape(str(subject))}

    ' @@ -411,7 +416,10 @@ def build_site(repository_root: Path, output_dir: Path) -> None: raise FileNotFoundError(f"PROV-O support profile is missing: {prov_profile}") if output.exists(): - shutil.rmtree(output) + raise FileExistsError( + "refusing to replace an existing output directory; " + "use publish_ontology_site for marked replacement" + ) ontology_dir = output / "ontology" ontology_dir.mkdir(parents=True) diff --git a/scripts/ontology_site_contract.py b/scripts/ontology_site_contract.py new file mode 100644 index 000000000..b82c4c5bc --- /dev/null +++ b/scripts/ontology_site_contract.py @@ -0,0 +1,8 @@ +"""Shared contracts for safe public ontology-site identifiers.""" + +from urllib.parse import quote + + +def public_fragment(fragment: str) -> str: + """Encode one local fragment identically for HTML IDs and hrefs.""" + return quote(fragment, safe="-._~") diff --git a/scripts/publish_ontology_site.py b/scripts/publish_ontology_site.py index fee47d3d3..bccdaa9f4 100644 --- a/scripts/publish_ontology_site.py +++ b/scripts/publish_ontology_site.py @@ -10,6 +10,7 @@ import argparse import importlib.util +import shutil from collections.abc import Iterable from pathlib import Path from types import ModuleType @@ -18,6 +19,11 @@ from rdflib import Graph, URIRef from rdflib.namespace import OWL, RDF, RDFS, SKOS +try: + from scripts.ontology_site_contract import public_fragment +except ModuleNotFoundError: # direct execution with ``scripts`` as sys.path[0] + from ontology_site_contract import public_fragment + OUTPUT_MARKER = ".lineageweave-ontology-site" SOURCE_RELATIVE_PATH = Path("docs/ontology/lineageweave-kg.ttl") PROV_PROFILE_RELATIVE_PATH = Path("docs/ontology/prov-o-support-profile.ttl") @@ -75,7 +81,7 @@ def validate_public_graph(graph: Graph) -> None: subjects = _public_subjects(graph) fragment_owner: dict[str, URIRef] = {} for subject in sorted(subjects, key=str): - fragment = _fragment(subject) + fragment = public_fragment(_fragment(subject)) owner = fragment_owner.setdefault(fragment, subject) if owner != subject: raise ValueError( @@ -122,6 +128,8 @@ def publish_site(repository_root: Path, output_dir: Path) -> None: validate_public_graph(graph) renderer = _load_renderer(root) + if output.exists(): + shutil.rmtree(output) renderer.build_site(root, output) (output / OUTPUT_MARKER).write_text("", encoding="utf-8") diff --git a/tests/test_ontology_site.py b/tests/test_ontology_site.py index e3638cbe5..662d28fc1 100644 --- a/tests/test_ontology_site.py +++ b/tests/test_ontology_site.py @@ -5,6 +5,7 @@ import hashlib import importlib.util import json +import shutil from pathlib import Path from rdflib import Graph @@ -95,10 +96,22 @@ def test_render_term_uses_skos_preferred_label_without_rdfs_label() -> None: rendered = builder._render_term(graph, term, {term}) - assert ">Human label" in rendered + assert "Human label" in rendered assert 'aria-label="Link to Human label"' in rendered +def test_render_term_uses_one_encoded_fragment_for_id_and_href() -> None: + builder = _load_builder() + graph = Graph() + term = builder.URIRef("https://example.test/ontology#Safety/한국어 term") + graph.add((term, builder.RDF.type, builder.OWL.Class)) + + rendered = builder._render_term(graph, term, {term}) + + assert 'id="Safety%2F%ED%95%9C%EA%B5%AD%EC%96%B4%20term"' in rendered + assert 'href="#Safety%2F%ED%95%9C%EA%B5%AD%EC%96%B4%20term"' in rendered + + def test_serializations_round_trip_to_the_source_graph(tmp_path: Path) -> None: builder = _load_builder() output = tmp_path / "site" @@ -173,7 +186,7 @@ def test_render_term_sections_keeps_one_anchor_for_multi_typed_terms() -> None: assert term_count == 1 -def test_builder_fails_closed_for_missing_sources_and_replaces_output(tmp_path: Path) -> None: +def test_builder_fails_closed_for_missing_sources_and_rejects_existing_output(tmp_path: Path) -> None: builder = _load_builder() repository = tmp_path / "repository" output = tmp_path / "site" @@ -201,8 +214,17 @@ def test_builder_fails_closed_for_missing_sources_and_replaces_output(tmp_path: raise AssertionError("missing PROV-O profile was accepted") (ontology_dir / "prov-o-support-profile.ttl").write_text("", encoding="utf-8") + + try: + builder.build_site(repository, output) + except FileExistsError as exc: + assert "publish_ontology_site" in str(exc) + else: + raise AssertionError("direct builder replaced an existing output") + assert (output / "stale.txt").is_file() + shutil.rmtree(output) builder.build_site(repository, output) - assert not (output / "stale.txt").exists() + assert (output / "ontology" / "index.html").is_file() def test_cli_main_and_module_entrypoint(tmp_path: Path, monkeypatch) -> None: diff --git a/tests/test_publish_ontology_site.py b/tests/test_publish_ontology_site.py index bec404ac2..166ee3411 100644 --- a/tests/test_publish_ontology_site.py +++ b/tests/test_publish_ontology_site.py @@ -33,6 +33,9 @@ def _repository_fixture(tmp_path: Path) -> Path: (scripts_dir / "build_ontology_site.py").write_bytes( (ROOT / "scripts" / "build_ontology_site.py").read_bytes() ) + (scripts_dir / "ontology_site_contract.py").write_bytes( + (ROOT / "scripts" / "ontology_site_contract.py").read_bytes() + ) return repository From 84fd2993fcec5d3d683c391818f85e27ebd7347f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 22:20:54 +0900 Subject: [PATCH 09/22] test: match ontology fragment link markup --- tests/test_ontology_site.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_ontology_site.py b/tests/test_ontology_site.py index e3638cbe5..46f16bfd2 100644 --- a/tests/test_ontology_site.py +++ b/tests/test_ontology_site.py @@ -95,7 +95,7 @@ def test_render_term_uses_skos_preferred_label_without_rdfs_label() -> None: rendered = builder._render_term(graph, term, {term}) - assert ">Human label" in rendered + assert "Human label" in rendered assert 'aria-label="Link to Human label"' in rendered From aab1e60c2c2ec4b57ca34585e5539d5ce1a7f9dd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 23:24:24 +0900 Subject: [PATCH 10/22] fix: keep unauthenticated login build type-safe --- frontend/src/App.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6fba0dd41..666888a4d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -101,7 +101,6 @@ import { tf, useLocale, } from "./i18n"; -import { rememberOidcReturnUrl, returnUrlFromLocation } from "./oidcReturnUrl"; import "./App.css"; function orchestratorUnavailableMessage(err: unknown, action: string): string { @@ -4620,7 +4619,6 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean Enterprise SSO Authentication - {destination === "admin" ? : null}