diff --git a/.github/workflows/ontology-pages.yml b/.github/workflows/ontology-pages.yml
new file mode 100644
index 000000000..05e7000bb
--- /dev/null
+++ b/.github/workflows/ontology-pages.yml
@@ -0,0 +1,138 @@
+name: Ontology Pages
+
+on:
+ pull_request:
+ branches: [main]
+ paths:
+ - "docs/ontology/**"
+ - "scripts/build_ontology_site.py"
+ - "scripts/publish_ontology_site.py"
+ - "scripts/ontology_site_contract.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"
+ - "scripts/ontology_site_contract.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
+ scripts/ontology_site_contract.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/0154-published-ontology-pages.md b/docs/adr/0154-published-ontology-pages.md
new file mode 100644
index 000000000..62737b5db
--- /dev/null
+++ b/docs/adr/0154-published-ontology-pages.md
@@ -0,0 +1,109 @@
+# ADR 0154 — 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 2690923bc..ff3563e6a 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -65,8 +65,23 @@ corpus acceptance or protected release.
| Accessibility and responsive UX | Unit coverage exists for major buyer surfaces | Keyboard, screen-reader, mobile, and authenticated Playwright acceptance on the exact release head |
| External integrations | SearXNG, Zotero, calendar, and downstream consumer contracts are bounded | Provider conformance, failure/reconciliation behavior, and provenance-bearing integration evidence |
| Release quality | Local focused/full suites have passed on individual PR heads | Repository-wide coverage, docstrings, Storybook, security, browser, and release evidence on one exact head |
+| Public ontology | PR #373 contains the deterministic Pages publication path | Protected merge, GitHub Actions Pages source, successful main deployment, and stable term-fragment dereference evidence |
-## 4. Evidence boundaries
+## 4. Public ontology publication boundary
+
+- PR #373 publishes fragment-addressable HTML, byte-identical Turtle,
+ isomorphic JSON-LD and N-Triples, the PROV-O support profile, and a
+ source-digest manifest from the authoritative ontology.
+- Pull requests validate only. Only protected `main` may publish, and the
+ generated-directory marker, linked-IRI, duplicate-fragment, symlink, and
+ source-overlap checks fail closed.
+- The lowercase knowledge-graph namespace and repository-case support-profile
+ namespace remain distinct until issue #372 delivers a versioned migration
+ and compatibility decision; this publication PR rewrites neither identity.
+- Until the protected deployment and exact URL checks succeed, the public
+ ontology endpoint remains unavailable and must not be represented as live.
+
+## 5. Evidence boundaries
- Never add a real record, title, name, identifier, screenshot, log, benchmark
artifact, or documentation example to this repository.
@@ -79,7 +94,7 @@ corpus acceptance or protected release.
- Re-fetch base/head SHAs, checks, review threads, approvals, rulesets, and the
merge SHA immediately before any lifecycle claim.
-## 5. Next acceptance loop
+## 6. Next acceptance loop
1. Complete the in-flight Strix rerun on PR #387 at the same exact head and
verify the merged central scope repair removed the false finding.
diff --git a/scripts/build_ontology_site.py b/scripts/build_ontology_site.py
new file mode 100644
index 000000000..16077c117
--- /dev/null
+++ b/scripts/build_ontology_site.py
@@ -0,0 +1,477 @@
+#!/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
+
+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
+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: (
+ 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
+
+
+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"#{public_fragment(_fragment(value))}"
+ 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."""
+ raw_fragment = _fragment(subject)
+ fragment = public_fragment(raw_fragment)
+ label = (
+ _preferred_literal(graph, subject, RDFS.label)
+ or _preferred_literal(graph, subject, SKOS.prefLabel)
+ or raw_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'' 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 _preferred_literal(graph, subject, SKOS.prefLabel)
+ 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)} "
+ '' + "".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"
+ ""
+ ""
+ ''
+ '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.
'
+ 'Term categories "
+ 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""
+ ""
+ ' '
+ ""
+ "\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():
+ 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)
+
+ 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/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
new file mode 100644
index 000000000..a6ee2f584
--- /dev/null
+++ b/scripts/publish_ontology_site.py
@@ -0,0 +1,145 @@
+#!/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
+import shutil
+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 RDF
+
+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")
+
+
+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, renderer: ModuleType) -> set[URIRef]:
+ """Return URI subjects included in the renderer's public term inventory."""
+ return {
+ subject
+ for _, term_type in renderer.TERM_TYPES
+ for subject in graph.subjects(RDF.type, term_type)
+ if isinstance(subject, URIRef)
+ }
+
+
+def validate_public_graph(graph: Graph, renderer: ModuleType) -> None:
+ """Reject renderer-visible RDF that cannot be published safely."""
+ subjects = _public_subjects(graph, renderer)
+ fragment_owner: dict[str, URIRef] = {}
+ for subject in sorted(subjects, key=str):
+ fragment = public_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 (RDF.type, *(item[1] for item in renderer.RELATION_FIELDS)):
+ 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)
+ renderer = _load_renderer(root)
+ graph = Graph().parse(source, format="turtle")
+ validate_public_graph(graph, renderer)
+
+ if output.exists():
+ shutil.rmtree(output)
+ 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..36b29e37d
--- /dev/null
+++ b/tests/test_ontology_site.py
@@ -0,0 +1,276 @@
+"""Contract tests for the deterministic LineageWeave ontology Pages site."""
+
+from __future__ import annotations
+
+import builtins
+import hashlib
+import importlib.util
+import json
+import shutil
+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 test_builder_supports_direct_script_import_context(monkeypatch) -> None:
+ """The CLI fallback imports the sibling contract when not package-loaded."""
+ monkeypatch.syspath_prepend(str(SCRIPT.parent))
+ original_import = builtins.__import__
+
+ def block_package_import(name, globals=None, locals=None, fromlist=(), level=0):
+ if name == "scripts.ontology_site_contract":
+ raise ModuleNotFoundError(name=name)
+ return original_import(name, globals, locals, fromlist, level)
+
+ monkeypatch.setattr(builtins, "__import__", block_package_import)
+ spec = importlib.util.spec_from_file_location("build_ontology_site_direct", SCRIPT)
+ assert spec is not None and spec.loader is not None
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ assert module.public_fragment("Safety/한국어 term") == "Safety%2F%ED%95%9C%EA%B5%AD%EC%96%B4%20term"
+
+
+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_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_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_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"
+ 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_rejects_existing_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")
+
+ 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 (output / "ontology" / "index.html").is_file()
+
+
+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..6dd9bd04d
--- /dev/null
+++ b/tests/test_publish_ontology_site.py
@@ -0,0 +1,210 @@
+"""Security and deployment-boundary tests for ontology Pages publication."""
+
+from __future__ import annotations
+
+import builtins
+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 test_publisher_supports_direct_script_import_context(monkeypatch) -> None:
+ """The CLI fallback imports the sibling contract when not package-loaded."""
+ monkeypatch.syspath_prepend(str(SCRIPT.parent))
+ original_import = builtins.__import__
+
+ def block_package_import(name, globals=None, locals=None, fromlist=(), level=0):
+ if name == "scripts.ontology_site_contract":
+ raise ModuleNotFoundError(name=name)
+ return original_import(name, globals, locals, fromlist, level)
+
+ monkeypatch.setattr(builtins, "__import__", block_package_import)
+ spec = importlib.util.spec_from_file_location("publish_ontology_site_direct", SCRIPT)
+ assert spec is not None and spec.loader is not None
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ assert module.public_fragment("Safety/한국어 term") == "Safety%2F%ED%95%9C%EA%B5%AD%EC%96%B4%20term"
+
+
+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()
+ )
+ (scripts_dir / "ontology_site_contract.py").write_bytes(
+ (ROOT / "scripts" / "ontology_site_contract.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()
+ renderer = publisher._load_renderer(ROOT)
+ 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, renderer)
+
+ 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, renderer)
+
+
+def test_graph_validation_allows_http_relations_and_multiple_term_types() -> None:
+ publisher = _load_publisher()
+ renderer = publisher._load_renderer(ROOT)
+ 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, renderer)
+
+
+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()
+ renderer = publisher._load_renderer(ROOT)
+ 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, renderer)
+
+
+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()