sphinx-traceability-view 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """Public Sphinx extension entry point; implementation belongs to directive."""
2
+ from .directive import setup
3
+ from ._version import __version__
@@ -0,0 +1,2 @@
1
+ """Canonical development/package version; change only this file for version updates."""
2
+ __version__ = "1.0.0"
@@ -0,0 +1,39 @@
1
+ """Content-addressed classic scripts, committed only after complete extraction."""
2
+ import hashlib
3
+ import json
4
+ import os
5
+
6
+
7
+ def encoded(value):
8
+ return json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")).replace("<", "\\u003c")
9
+
10
+
11
+ def digest(value):
12
+ return hashlib.sha256(value.encode("utf-8")).hexdigest()
13
+
14
+
15
+ def atomic(path, value):
16
+ path.parent.mkdir(parents=True, exist_ok=True)
17
+ temporary = path.with_suffix(path.suffix + ".tmp")
18
+ temporary.write_text(value, encoding="utf-8")
19
+ os.replace(temporary, path)
20
+
21
+
22
+ def emit(graph, fragments, output):
23
+ graph = dict(graph, details={})
24
+ files = {}
25
+ for identity, fragment in fragments.items():
26
+ content_hash = digest(fragment)
27
+ name = f"_static/stv/details/{content_hash}.js"
28
+ graph["details"][identity] = {"url": name, "digest": content_hash}
29
+ files[name] = 'globalThis.__STV_DATA__ ||= {graphs:{},fragments:{}};globalThis.__STV_DATA__.fragments[' + encoded(content_hash) + '] = ' + encoded(fragment) + ';'
30
+ identity = digest(encoded(graph))
31
+ graph["identity"] = identity
32
+ name = f"_static/stv/graph-{identity}.js"
33
+ # JSON.parse preserves data keys such as __proto__; object-literal source
34
+ # would interpret that key as a prototype assignment. Null dictionaries
35
+ # also prevent missing IDs such as "constructor" resolving through inheritance.
36
+ files[name] = 'globalThis.__STV_DATA__ ||= {graphs:{},fragments:{}};globalThis.__STV_DATA__.graphs[' + encoded(identity) + '] = JSON.parse(' + encoded(encoded(graph)) + ',(_,v)=>v&&typeof v==="object"&&!Array.isArray(v)?Object.assign(Object.create(null),v):v);'
37
+ for relative, content in files.items():
38
+ atomic(output / relative, content)
39
+ return identity, name
@@ -0,0 +1,148 @@
1
+ """Extract static host markup and make local resources portable."""
2
+ from copy import copy
3
+ from html import escape
4
+ import posixpath
5
+ import re
6
+ from urllib.parse import urlsplit, urlunsplit, unquote
7
+ from bs4 import BeautifulSoup
8
+
9
+
10
+ def static_css(css, page):
11
+ resources = []
12
+ def url(match):
13
+ raw = match.group(1).strip().strip('\"\'')
14
+ target = portable_url(raw, page)
15
+ parsed = urlsplit(target or "")
16
+ if target and (parsed.netloc or parsed.scheme in ("http", "https")):
17
+ resources.append(target)
18
+ return "none"
19
+ if not target or parsed.scheme not in ("", "data"):
20
+ return "none"
21
+ if parsed.scheme == "data" and not target.lower().startswith(("data:image/png", "data:image/jpeg", "data:image/gif", "data:image/webp")):
22
+ return "none"
23
+ return 'url("' + target.replace('"', '%22') + '")'
24
+ def imported(match):
25
+ targets = re.findall(r'(?:url\(\s*)?[\"\']([^\"\']+)[\"\']', match.group())
26
+ resources.extend(target for value in targets if (target := portable_url(value, page)))
27
+ return ""
28
+ css = re.sub(r'@import\s+[^;]+;?', imported, css, flags=re.I)
29
+ return re.sub(r'url\(\s*([^)]*)\)', url, css, flags=re.I), resources
30
+
31
+
32
+ def portable_url(value, page):
33
+ parsed = urlsplit(value.strip())
34
+ if parsed.scheme.lower() not in ("", "http", "https", "mailto", "tel", "data"):
35
+ return None
36
+ if parsed.scheme or parsed.netloc:
37
+ return value
38
+ path = posixpath.normpath(posixpath.join(posixpath.dirname(page), parsed.path)) if parsed.path else page
39
+ if path.startswith("../") or path.startswith("/"):
40
+ return None
41
+ return urlunsplit(("", "", path, parsed.query, parsed.fragment))
42
+
43
+
44
+ def transform(markup, page, hidden, relation_names=()):
45
+ soup = BeautifulSoup(markup, "html.parser")
46
+ relation_names = set(relation_names)
47
+ for tag in list(soup.find_all(True)):
48
+ if tag.parent is None:
49
+ continue
50
+ if tag.name in ("script", "iframe", "object", "embed", "link", "base", "form", "meta"):
51
+ source = tag.get("src") or tag.get("data") or tag.get("href")
52
+ source = portable_url(source, page) if source else None
53
+ if source and urlsplit(source).scheme == "data":
54
+ source = None
55
+ if source:
56
+ placeholder = soup.new_tag("span")
57
+ placeholder.string = "Embedded resource unavailable offline. "
58
+ link = soup.new_tag("a", href=source)
59
+ if not urlsplit(source).scheme and not urlsplit(source).netloc:
60
+ link["data-stv-href"] = source
61
+ link.string = "Original resource"
62
+ placeholder.append(link)
63
+ tag.replace_with(placeholder)
64
+ else:
65
+ tag.decompose()
66
+ continue
67
+ if tag.name == "a" and hidden and unquote(urlsplit(tag.get("href", "")).fragment) in hidden:
68
+ relationship_reference = any(set(parent.get("class", [])) & relation_names
69
+ for parent in tag.parents if parent.name)
70
+ if relationship_reference:
71
+ tag.decompose()
72
+ continue
73
+ for attr in list(tag.attrs):
74
+ if attr.lower().startswith(("on", "data-stv-")):
75
+ del tag[attr]
76
+ resources = []
77
+ for attr in ("href", "xlink:href", "src", "poster"):
78
+ if attr not in tag.attrs:
79
+ continue
80
+ value = portable_url(tag[attr], page)
81
+ parsed = urlsplit(value or "")
82
+ embedded = attr != "href" or tag.name != "a"
83
+ if value is None or (parsed.scheme == "data" and not (embedded and value.lower().startswith(("data:image/png", "data:image/jpeg", "data:image/gif", "data:image/webp")))):
84
+ del tag[attr]
85
+ elif embedded and (parsed.netloc or parsed.scheme in ("http", "https")):
86
+ resources.append(value)
87
+ del tag[attr]
88
+ else:
89
+ tag[attr] = value
90
+ if not parsed.scheme and not parsed.netloc:
91
+ tag[f"data-stv-{attr.replace(':', '-')}"] = value
92
+ if tag.has_attr("srcset"):
93
+ value = tag["srcset"]
94
+ del tag["srcset"]
95
+ sources = []
96
+ candidates = re.finditer(r'\s*(data:[^\s]+|[^,\s]+)(?:\s+([0-9.]+[wx]))?\s*(?:,|$)', value)
97
+ for candidate in candidates:
98
+ url = portable_url(candidate[1].rstrip(','), page)
99
+ descriptor = candidate[2] or ""
100
+ permitted_data = url and url.lower().startswith(("data:image/png", "data:image/jpeg", "data:image/gif", "data:image/webp"))
101
+ if url and ((not urlsplit(url).scheme and not urlsplit(url).netloc) or permitted_data):
102
+ sources.append((url + " " + descriptor).strip())
103
+ elif url:
104
+ resources.append(url)
105
+ if sources:
106
+ tag["data-stv-srcset"] = ", ".join(sources)
107
+ if tag.has_attr("style") or tag.name == "style":
108
+ text = tag.get("style", "") if tag.name != "style" else tag.get_text()
109
+ css, css_resources = static_css(text, page)
110
+ resources.extend(css_resources)
111
+ if tag.name == "style":
112
+ tag.string = css
113
+ tag["data-stv-css"] = css
114
+ else:
115
+ tag["style"] = css
116
+ tag["data-stv-style"] = css
117
+ for url in resources:
118
+ note = soup.new_tag("span", attrs={"class": "stv-unavailable"})
119
+ note.string = "Resource unavailable offline. "
120
+ link = soup.new_tag("a", href=url)
121
+ if not urlsplit(url).scheme and not urlsplit(url).netloc:
122
+ link["data-stv-href"] = url
123
+ link.string = "Original resource"
124
+ note.append(link)
125
+ tag.insert_after(note)
126
+ return str(soup)
127
+
128
+
129
+ def extract(graph, output, hidden):
130
+ pages = {}
131
+ fragments = {}
132
+ for identity, obj in graph["objects"].items():
133
+ if obj["external"]:
134
+ fields = "".join(f"<dt>{escape(k)}</dt><dd>{escape(str(v))}</dd>" for k, v in obj.get("metadata", obj).items() if v is not None)
135
+ fragments[identity] = f'<div><p>External object: full body unavailable offline.</p><dl>{fields}</dl></div>'
136
+ continue
137
+ page = urlsplit(obj["url"]).path
138
+ if page not in pages:
139
+ path = (output / page).resolve()
140
+ if not path.is_relative_to(output.resolve()):
141
+ raise ValueError(f"Object page escapes output directory: {page}")
142
+ soup = BeautifulSoup(path.read_text(encoding="utf-8"), "html.parser")
143
+ pages[page] = {tag["id"]: tag for tag in soup.find_all(id=True)}
144
+ original = pages[page].get(obj["parent"] or identity)
145
+ if original is None:
146
+ raise ValueError(f"Rendered Need {identity!r} missing in {page}")
147
+ fragments[identity] = transform(str(copy(original)), page, hidden, graph["model"]["relations"])
148
+ return fragments
@@ -0,0 +1,2 @@
1
+ """STV_SWE_DIRECTIVE: Sphinx lifecycle and directive integration."""
2
+ from .lifecycle import setup
@@ -0,0 +1,36 @@
1
+ """Serializable directive records and a small page-local initialization node."""
2
+ import json
3
+ from hashlib import sha256
4
+ from html import escape
5
+ from docutils import nodes
6
+ from sphinx.util.docutils import SphinxDirective
7
+
8
+
9
+ class ViewNode(nodes.General, nodes.Element):
10
+ pass
11
+
12
+
13
+ class TraceabilityView(SphinxDirective):
14
+ required_arguments = 0
15
+ optional_arguments = 1
16
+ final_argument_whitespace = True
17
+ has_content = False
18
+
19
+ def run(self):
20
+ seeds = list(dict.fromkeys(self.arguments[0].split())) if self.arguments else []
21
+ if len(seeds) > 200:
22
+ raise self.error("traceability-view: at most 200 distinct starting IDs are permitted")
23
+ records = getattr(self.env, "stv_views", {})
24
+ entries = records.setdefault(self.env.docname, [])
25
+ document_key = sha256(self.env.docname.encode("utf-8")).hexdigest()
26
+ record = {"id": f"stv-{document_key}-{len(entries)}", "seeds": seeds,
27
+ "source": self.get_source_info()[0], "line": self.lineno}
28
+ entries.append(record)
29
+ self.env.stv_views = records
30
+ return [ViewNode("", **record)]
31
+
32
+
33
+ def visit_html(translator, node):
34
+ data = escape(json.dumps(node["seeds"]), quote=True)
35
+ translator.body.append(f'<section class="stv-view" id="{escape(node["id"], quote=True)}" data-stv-seeds="{data}" data-stv-build="STV_PENDING" aria-label="Interactive traceability view"></section>')
36
+ raise nodes.SkipNode
@@ -0,0 +1,112 @@
1
+ """Sphinx entry point; the host owns Needs collection and trace diagnostics."""
2
+ from copy import deepcopy
3
+ from hashlib import sha256
4
+ import shutil
5
+ from html import escape
6
+ from pathlib import Path
7
+ from sphinx.errors import SphinxError
8
+ from sphinx.util.osutil import relative_uri
9
+ from sphinx_needs.data import SphinxNeedsData
10
+ from .directives import TraceabilityView, ViewNode, visit_html
11
+ from .._version import __version__
12
+ from ..model import normalize, validate_details_limit, DETAILS_LIMIT_DEFAULT
13
+ from ..graph import snapshot
14
+ from ..detail_build import extract
15
+ from ..bundle import emit, atomic
16
+
17
+
18
+ def initialize(app):
19
+ # The host later injects unused GitHub service types unconditionally. Validate
20
+ # the author's schema plus types actually used by service-produced objects.
21
+ app._stv_declared_types = deepcopy(app.config.needs_types)
22
+
23
+
24
+ def purge(app, env, docname):
25
+ getattr(env, "stv_views", {}).pop(docname, None)
26
+
27
+
28
+ def outdated(app, env, added, changed, removed):
29
+ return sorted(env.found_docs) if getattr(env, "stv_views", {}) else []
30
+
31
+
32
+ def prepare(app, builder):
33
+ views = getattr(app.env, "stv_views", {})
34
+ app._stv_graph = None
35
+ if not any(views.values()):
36
+ return
37
+ if builder.name != "html":
38
+ raise SphinxError("traceability-view supports only the html builder")
39
+ records = [record for entries in views.values() for record in entries]
40
+ location = f'{records[0]["source"]}:{records[0]["line"]}'
41
+ try:
42
+ details_limit = validate_details_limit(app.config.traceability_view_details_limit)
43
+ needs = SphinxNeedsData(app.env).get_needs_view()
44
+ declared = {t["directive"] for t in app._stv_declared_types}
45
+ used = {n["type"] for n in needs.values()}
46
+ host_types = [t for t in app.config.needs_types if t["directive"] in declared | used]
47
+ model = normalize(host_types, app.config.needs_links,
48
+ app.config.traceability_view_model,
49
+ any(not r["seeds"] for r in records))
50
+ graph, hidden = snapshot(needs, model,
51
+ builder.get_target_uri, app.config.project)
52
+ graph["details_limit"] = details_limit
53
+ for record in records:
54
+ for seed in record["seeds"]:
55
+ if seed not in graph["objects"]:
56
+ raise SphinxError(f'{record["source"]}:{record["line"]}: traceability-view: unknown or hidden starting ID {seed!r}')
57
+ app._stv_graph, app._stv_hidden = graph, hidden
58
+ except ValueError as error:
59
+ raise SphinxError(f"{location}: traceability-view: {error}") from error
60
+ target = Path(app.outdir) / "_static" / "stv"
61
+ target.mkdir(parents=True, exist_ok=True)
62
+ for name in ("stv.js", "stv.css", "LICENSE.txt", "THIRD_PARTY_NOTICES.txt", "vendor/ELK-SOURCE.txt", "vendor/ELK-INVENTORY.json"):
63
+ source = Path(__file__).parents[1] / "static" / name
64
+ if not source.is_file():
65
+ raise SphinxError(f"Missing packaged STV asset {name}; maintainers must build frontend assets")
66
+ (target / name).parent.mkdir(parents=True, exist_ok=True)
67
+ shutil.copyfile(source, target / name)
68
+
69
+
70
+ def context(app, pagename, templatename, context, doctree):
71
+ if not getattr(app.env, "stv_views", {}).get(pagename):
72
+ return
73
+ page = app.builder.get_target_uri(pagename)
74
+ root = relative_uri(page, "index.html").removesuffix("index.html") or "./"
75
+ static = Path(__file__).parents[1] / "static"
76
+ css = escape(root + "_static/stv/stv.css?v=" + sha256((static / "stv.css").read_bytes()).hexdigest(), quote=True)
77
+ runtime = escape(root + "_static/stv/stv.js?v=" + sha256((static / "stv.js").read_bytes()).hexdigest(), quote=True)
78
+ context["metatags"] += f'<link rel="stylesheet" href="{css}"><script data-stv-graph src="STV_PENDING"></script><script defer src="{runtime}" data-stv-root="{escape(root, quote=True)}"></script>'
79
+
80
+
81
+ def finish(app, exception):
82
+ graph = getattr(app, "_stv_graph", None)
83
+ if graph is None or exception is not None:
84
+ return
85
+ output = Path(app.outdir)
86
+ try:
87
+ fragments = extract(graph, output, app._stv_hidden)
88
+ identity, graph_path = emit(graph, fragments, output)
89
+ for docname in getattr(app.env, "stv_views", {}):
90
+ page = app.builder.get_target_uri(docname)
91
+ path = output / page
92
+ content = path.read_text(encoding="utf-8")
93
+ content = content.replace('data-stv-build="STV_PENDING"', f'data-stv-build="{identity}"')
94
+ content = content.replace('data-stv-graph src="STV_PENDING"', f'data-stv-graph src="{escape(relative_uri(page, graph_path), quote=True)}"')
95
+ atomic(path, content)
96
+ except (OSError, ValueError) as error:
97
+ raise SphinxError(f"traceability-view bundle finalization failed: {error}") from error
98
+
99
+
100
+ def setup(app):
101
+ app.setup_extension("sphinx_needs")
102
+ app.add_config_value("traceability_view_model", None, "env", types=[dict, type(None)])
103
+ app.add_config_value("traceability_view_details_limit", DETAILS_LIMIT_DEFAULT, "env", types=[int])
104
+ app.add_directive("traceability-view", TraceabilityView)
105
+ app.add_node(ViewNode, html=(visit_html, None))
106
+ app.connect("builder-inited", initialize)
107
+ app.connect("env-purge-doc", purge)
108
+ app.connect("env-get-outdated", outdated)
109
+ app.connect("write-started", prepare, priority=800)
110
+ app.connect("html-page-context", context)
111
+ app.connect("build-finished", finish, priority=900)
112
+ return {"version": __version__, "parallel_read_safe": False, "parallel_write_safe": False}
@@ -0,0 +1,55 @@
1
+ """Adapt the completed NeedsView into deterministic, bidirectional graph data."""
2
+ from urllib.parse import urlsplit
3
+ from collections.abc import Mapping
4
+ from .placement import resolve_placements
5
+
6
+
7
+ def metadata_value(value):
8
+ if isinstance(value, Mapping):
9
+ return {str(key): metadata_value(item) for key, item in value.items()}
10
+ if isinstance(value, (list, tuple)):
11
+ return [metadata_value(item) for item in value]
12
+ if isinstance(value, (str, int, float, bool, type(None))):
13
+ return value
14
+ return str(value)
15
+
16
+
17
+ def snapshot(needs, model, uri, project):
18
+ records = list(needs.to_list_with_parts())
19
+ hidden = {n.get("id_complete", n["id"]) for n in records if n.get("hide")}
20
+ hidden.update(n.get("id_complete", n["id"]) for n in records if n.get("id_parent") in hidden)
21
+ objects = {}
22
+ for n in records:
23
+ identity = n.get("id_complete", n["id"])
24
+ if identity in hidden:
25
+ continue
26
+ original = n.get("external_url") if n.get("is_external") else uri(n["docname"]) + "#" + identity
27
+ if not original or urlsplit(original).scheme.lower() not in ("", "http", "https"):
28
+ raise ValueError(f"Need {identity!r}: original documentation URL must be a local path or HTTP(S) URL")
29
+ objects[identity] = {"id": identity, "title": n.get("title", n.get("content", "")),
30
+ "type": n["type"], "status": n.get("status") or "",
31
+ "url": original, "docname": n.get("docname"),
32
+ "parent": n.get("id_parent") if n.get("is_part") else None,
33
+ "external": bool(n.get("is_external"))}
34
+ if n.get("is_external"):
35
+ excluded = set(model["relations"]) | {name + "_back" for name in model["relations"]} | {"parts"}
36
+ objects[identity]["metadata"] = {key: metadata_value(value) for key, value in n.items()
37
+ if key not in excluded}
38
+ edges = set()
39
+ for n in records:
40
+ source = n.get("id_complete", n["id"])
41
+ if source not in objects:
42
+ continue
43
+ for relation in model["relations"]:
44
+ for target in n.get(relation, []):
45
+ if target not in hidden:
46
+ edges.add((source, relation, target))
47
+ adjacency = {i: {"incoming": {}, "outgoing": {}} for i in objects}
48
+ for source, relation, target in sorted(edges):
49
+ adjacency[source]["outgoing"].setdefault(relation, []).append(target)
50
+ if target in objects:
51
+ adjacency[target]["incoming"].setdefault(relation, []).append(source)
52
+ resolve_placements({n.get("id_complete", n["id"]): n for n in records}, objects, adjacency, model)
53
+ return {"schema": 1, "project": project, "model": model,
54
+ "objects": dict(sorted(objects.items())), "edges": sorted(edges), "adjacency": adjacency,
55
+ "entries": sorted(i for i, n in objects.items() if not n["parent"] and model["types"][n["type"]].get("role") == "stakeholder")}, hidden
@@ -0,0 +1,48 @@
1
+ """Resolve evidence placement from complete published metadata, never view state."""
2
+
3
+
4
+ def resolve_placements(records, objects, adjacency, model):
5
+ types = model["types"]
6
+ resolved = {}
7
+
8
+ def assign(identity, choices):
9
+ placement = types[objects[identity]["type"]]
10
+ if len(choices) == 1:
11
+ level = next(iter(choices))
12
+ else:
13
+ level = placement.get("fallback_level")
14
+ if level is None:
15
+ raise ValueError(f"Need {identity!r}: placement classification is missing or conflicting")
16
+ objects[identity]["placement_fallback"] = True
17
+ objects[identity]["level"] = level
18
+ resolved[identity] = level
19
+
20
+ for identity, obj in objects.items():
21
+ placement = types[obj["type"]]
22
+ if "level" in placement:
23
+ resolved[identity] = placement["level"]
24
+ elif "levels_by_tag" in placement:
25
+ tags = records[identity].get("tags") or []
26
+ if not isinstance(tags, (list, tuple, set, frozenset)):
27
+ tags = []
28
+ assign(identity, {level for tag, level in placement["levels_by_tag"].items() if tag in tags})
29
+
30
+ for identity, obj in objects.items():
31
+ placement = types[obj["type"]]
32
+ if "level_from" not in placement:
33
+ continue
34
+ rule = placement["level_from"]
35
+ owners = adjacency[identity][rule["direction"]].get(rule["relation"], [])
36
+ choices = set()
37
+ complete = bool(owners)
38
+ for owner in owners:
39
+ other = objects.get(owner)
40
+ if other and "level_from" in types[other["type"]]:
41
+ complete = False
42
+ continue
43
+ target = rule["levels"].get(resolved.get(owner))
44
+ if target is None or (other and other.get("placement_fallback")):
45
+ complete = False
46
+ else:
47
+ choices.add(target)
48
+ assign(identity, choices if complete else set())
@@ -0,0 +1,98 @@
1
+ """Validate layout metadata without altering the host schema."""
2
+ from copy import deepcopy
3
+
4
+ DEFAULT = {
5
+ "levels": ["stakeholder", "system", "system_arch", "software", "software_arch"],
6
+ "types": {"shr": {"level": "stakeholder", "role": "stakeholder"},
7
+ "sysr": {"level": "system"}, "syse": {"level": "system_arch"},
8
+ "swr": {"level": "software"}, "swe": {"level": "software_arch"}},
9
+ "accepted_statuses": ["accepted"],
10
+ }
11
+ PALETTE = ["#dceaf7", "#e1efdc", "#f7e5ca", "#e9ddf2", "#d8efeb"]
12
+
13
+
14
+ def normalize(host_types, host_links, supplied=None, project_mode=False):
15
+ model = deepcopy(DEFAULT if supplied is None else supplied)
16
+ if not isinstance(model, dict):
17
+ raise ValueError("traceability_view_model: expected a mapping")
18
+ levels = model.get("levels")
19
+ if not isinstance(levels, list) or not levels or any(not isinstance(x, str) or not x for x in levels) or len(set(levels)) != len(levels):
20
+ raise ValueError("traceability_view_model.levels: expected unique nonempty level names")
21
+ types = model.get("types", {})
22
+ if not isinstance(types, dict):
23
+ raise ValueError("traceability_view_model.types: expected a mapping")
24
+ names = {t["directive"] for t in host_types}
25
+ if set(types) != names:
26
+ raise ValueError(f"traceability_view_model.types: missing {sorted(names-set(types))}; unknown {sorted(set(types)-names)}")
27
+ host = {t["directive"]: t for t in host_types}
28
+ for index, name in enumerate(sorted(names)):
29
+ placement = types[name]
30
+ if not isinstance(placement, dict):
31
+ raise ValueError(f"traceability_view_model.types.{name}: expected a mapping")
32
+ role = placement.get("role")
33
+ selectors = [key for key in ("level", "levels_by_tag", "level_from") if key in placement]
34
+ path = f"traceability_view_model.types.{name}"
35
+ if role == "test":
36
+ if selectors or "fallback_level" in placement:
37
+ raise ValueError(f"{path}: legacy test role cannot have a level selector")
38
+ elif role not in (None, "stakeholder") or len(selectors) != 1:
39
+ raise ValueError(f"{path}: expected one level/role placement")
40
+ elif role == "stakeholder" and selectors != ["level"]:
41
+ raise ValueError(f"{path}: stakeholder role requires a fixed level")
42
+ elif selectors == ["level"]:
43
+ if placement["level"] not in levels or "fallback_level" in placement:
44
+ raise ValueError(f"{path}: invalid fixed level placement")
45
+ else:
46
+ selector = selectors[0]
47
+ value = placement[selector]
48
+ if selector == "level_from":
49
+ if (not isinstance(value, dict) or set(value) != {"relation", "direction", "levels"}
50
+ or not isinstance(value["relation"], str)
51
+ or value["direction"] not in ("incoming", "outgoing")):
52
+ raise ValueError(f"{path}.level_from: expected relation, direction and levels")
53
+ value = value["levels"]
54
+ if (not isinstance(value, dict) or not value
55
+ or any(not isinstance(key, str) or not key or not isinstance(target, str)
56
+ or target not in levels for key, target in value.items())):
57
+ raise ValueError(f"{path}.{selector}: expected nonempty level mapping")
58
+ if "fallback_level" in placement and placement["fallback_level"] not in levels:
59
+ raise ValueError(f"{path}.fallback_level: unknown level")
60
+ placement["color"] = host[name].get("color") or PALETTE[index % len(PALETTE)]
61
+ placement["title"] = host[name].get("title", name)
62
+ if project_mode and not any(t.get("role") == "stakeholder" for t in types.values()):
63
+ raise ValueError("traceability_view_model.types: project mode requires a stakeholder role")
64
+ statuses = model.setdefault("accepted_statuses", ["accepted"])
65
+ if not isinstance(statuses, list) or any(not isinstance(x, str) for x in statuses):
66
+ raise ValueError("traceability_view_model.accepted_statuses: expected a list of strings")
67
+ model["relations"] = {}
68
+ relations = ({"option": name, **value} for name, value in host_links.items()) if isinstance(host_links, dict) else host_links
69
+ for relation in relations:
70
+ name = relation["option"]
71
+ if any(not isinstance(relation.get(direction), str) or not relation[direction].strip()
72
+ for direction in ("incoming", "outgoing")):
73
+ raise ValueError(f"needs_links.{name}: incoming and outgoing labels are required")
74
+ model["relations"][name] = {k: relation[k] for k in ("incoming", "outgoing")}
75
+ owner_levels = {p["level"] for p in types.values() if "level" in p}
76
+ owner_levels.update(level for p in types.values() for level in p.get("levels_by_tag", {}).values())
77
+ owner_levels.update(p["fallback_level"] for p in types.values()
78
+ if "levels_by_tag" in p and "fallback_level" in p)
79
+ for name, placement in types.items():
80
+ if "level_from" not in placement:
81
+ continue
82
+ rule = placement["level_from"]
83
+ path = f"traceability_view_model.types.{name}.level_from"
84
+ if rule["relation"] not in model["relations"]:
85
+ raise ValueError(f"{path}.relation: unknown host relation")
86
+ if not set(rule["levels"]).issubset(owner_levels):
87
+ raise ValueError(f"{path}.levels: owners must use fixed/tag-resolved levels")
88
+ return model
89
+
90
+
91
+ DETAILS_LIMIT_DEFAULT = 10
92
+
93
+
94
+ def validate_details_limit(value=DETAILS_LIMIT_DEFAULT):
95
+ """Keep author capacity exact across Python and the JavaScript consumer."""
96
+ if type(value) is not int or not 1 <= value <= 9007199254740991:
97
+ raise ValueError("traceability_view_details_limit: expected a positive integer no greater than 9007199254740991")
98
+ return value
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Henning Heikes
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.