Skip to content

Commit 86fb62e

Browse files
feat(scripts_bazel): add needs.json -> LOBSTER lobster-req-trace converter
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent ba57f72 commit 86fb62e

5 files changed

Lines changed: 464 additions & 0 deletions

File tree

scripts_bazel/BUILD

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,11 @@ py_binary(
4545
visibility = ["//visibility:public"],
4646
deps = [],
4747
)
48+
49+
py_binary(
50+
name = "needs_to_lobster",
51+
srcs = ["needs_to_lobster.py"],
52+
main = "needs_to_lobster.py",
53+
visibility = ["//visibility:public"],
54+
deps = [],
55+
)

scripts_bazel/README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,29 @@
11
# Scripts Bazel
22

33
This folder contains executables to be used within Bazel rules.
4+
5+
## `needs_to_lobster`
6+
7+
Converts a sphinx-needs `needs.json` into a LOBSTER `lobster-req-trace`
8+
(`.lobster`) file so sphinx-needs requirements can be aggregated with the
9+
code/test `.lobster` artifacts by the LOBSTER CLI (`lobster-report` /
10+
`lobster-ci-report`). Authoring (RST) and rendering (sphinx-needs dashboards)
11+
are untouched; LOBSTER only sits downstream of `needs.json`.
12+
13+
```console
14+
bazel run //scripts_bazel:needs_to_lobster -- \
15+
--needs-json _build/needs.json \
16+
--output tool_reqs.lobster \
17+
--types tool_req \
18+
--up-links satisfies
19+
```
20+
21+
The `satisfies` links (e.g. `tool_req__* -> gd_req__*`) are emitted as LOBSTER
22+
`refs`, so the process-requirement <-> tool-requirement relationship authored in
23+
RST is preserved as a LOBSTER up-trace. Model the two sides as separate
24+
requirement levels in `lobster.conf` (`Tool Requirements` `trace to:`
25+
`Process Requirements`).
26+
27+
Licensing: LOBSTER is AGPLv3. This tool never imports LOBSTER; it writes the
28+
documented JSON schema and is consumed by the LOBSTER CLI as an external
29+
process, keeping the Apache-2.0 code at arm's length.

scripts_bazel/needs_to_lobster.py

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
# *******************************************************************************
2+
# Copyright (c) 2026 Contributors to the Eclipse Foundation
3+
#
4+
# See the NOTICE file(s) distributed with this work for additional
5+
# information regarding copyright ownership.
6+
#
7+
# This program and the accompanying materials are made available under the
8+
# terms of the Apache License Version 2.0 which is available at
9+
# https://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# SPDX-License-Identifier: Apache-2.0
12+
# *******************************************************************************
13+
14+
"""Convert a sphinx-needs ``needs.json`` into a LOBSTER ``lobster-req-trace`` file.
15+
16+
Data flow::
17+
18+
docs build -> sphinx-needs writes needs.json (single source of truth)
19+
this tool -> needs.json -> <module>.lobster (lobster-req-trace, v3)
20+
LOBSTER CLI -> lobster-report / lobster-ci-report over all *.lobster
21+
22+
The converter reads the already metamodel-validated ``needs.json`` and emits the
23+
LOBSTER common interchange format so ``lobster-report`` can aggregate the
24+
sphinx-needs requirements with the code/test ``.lobster`` artifacts produced by
25+
``rules_score``. Authoring (RST) and rendering (sphinx-needs dashboards) are
26+
untouched -- LOBSTER only sits downstream of ``needs.json``.
27+
28+
Licensing note: LOBSTER is AGPLv3. This converter deliberately produces the
29+
plain ``.lobster`` JSON via the documented schema and never imports the LOBSTER
30+
Python packages, keeping the Apache-2.0 code at arm's length from the AGPL tool.
31+
The emitted files are consumed by the LOBSTER CLI as an external process.
32+
33+
Schema reference: LOBSTER ``lobster-req-trace``, item version 3
34+
(``tag``, ``location``, ``name``, ``refs``, ``just_up``/``just_down``/
35+
``just_global``, plus requirement ``framework``/``kind``/``text``/``status``).
36+
"""
37+
38+
from __future__ import annotations
39+
40+
import argparse
41+
import json
42+
import sys
43+
from pathlib import Path
44+
from typing import Any
45+
46+
# LOBSTER lobster-req-trace item schema version we emit (see documentation/schemas.md).
47+
_LOBSTER_SCHEMA = "lobster-req-trace"
48+
_LOBSTER_VERSION = 3
49+
_GENERATOR = "needs_to_lobster"
50+
51+
# sphinx-needs link fields treated as up-traces (child -> parent) by default.
52+
# ``satisfies`` is how tool_req__* needs reference their process gd_req__* parents.
53+
_DEFAULT_UP_LINKS = ("satisfies",)
54+
55+
# Need fields that may already carry LOBSTER-style justifications, if authored.
56+
_JUST_FIELDS = ("just_up", "just_down", "just_global")
57+
58+
59+
def _select_version(needs_data: dict[str, Any], version: str | None) -> dict[str, Any]:
60+
"""Return the ``versions`` entry to convert.
61+
62+
Uses ``version`` when given, else ``current_version``, else the last key.
63+
"""
64+
versions = needs_data.get("versions")
65+
if not versions:
66+
raise ValueError("needs.json contains no 'versions' object")
67+
if version is not None:
68+
if version not in versions:
69+
raise ValueError(f"version {version!r} not found in needs.json")
70+
return versions[version]
71+
current = needs_data.get("current_version")
72+
if current and current in versions:
73+
return versions[current]
74+
return versions[list(versions)[-1]]
75+
76+
77+
def _as_id_list(value: Any) -> list[str]:
78+
"""Normalise a sphinx-needs link field into a list of target ids.
79+
80+
Link fields may be a list, a comma-separated string, or a single id.
81+
"""
82+
if value is None:
83+
return []
84+
if isinstance(value, list):
85+
return [str(item) for item in value if item]
86+
if isinstance(value, str):
87+
return [part.strip() for part in value.split(",") if part.strip()]
88+
return [str(value)]
89+
90+
91+
def _location(need: dict[str, Any]) -> dict[str, Any]:
92+
"""Build a LOBSTER 'file' source reference from a need's docname/lineno."""
93+
docname = need.get("docname")
94+
filename = f"{docname}.rst" if docname else "unknown"
95+
lineno = need.get("lineno")
96+
return {
97+
"kind": "file",
98+
"file": filename,
99+
"line": int(lineno) if isinstance(lineno, int) else None,
100+
"column": None,
101+
}
102+
103+
104+
def _refs(need: dict[str, Any], namespace: str, up_links: tuple[str, ...]) -> list[str]:
105+
"""Collect up-trace targets from the configured link fields as LOBSTER tags."""
106+
refs: list[str] = []
107+
seen: set[str] = set()
108+
for field in up_links:
109+
for target in _as_id_list(need.get(field)):
110+
tag = f"{namespace} {target}"
111+
if tag not in seen:
112+
seen.add(tag)
113+
refs.append(tag)
114+
return refs
115+
116+
117+
def _text(need: dict[str, Any]) -> str | None:
118+
"""Return the need body text, tolerating sphinx-needs field naming."""
119+
for field in ("content", "description"):
120+
value = need.get(field)
121+
if isinstance(value, str) and value.strip():
122+
return value
123+
return None
124+
125+
126+
def need_to_item(
127+
need: dict[str, Any],
128+
namespace: str,
129+
up_links: tuple[str, ...],
130+
) -> dict[str, Any]:
131+
"""Convert a single sphinx-needs need into a LOBSTER requirement item."""
132+
need_id = str(need["id"])
133+
item: dict[str, Any] = {
134+
"tag": f"{namespace} {need_id}",
135+
"location": _location(need),
136+
"name": need.get("title") or need_id,
137+
"messages": [],
138+
"just_up": _as_id_list(need.get("just_up")),
139+
"just_down": _as_id_list(need.get("just_down")),
140+
"just_global": _as_id_list(need.get("just_global")),
141+
"refs": _refs(need, namespace, up_links),
142+
"framework": "sphinx-needs",
143+
"kind": need.get("type") or "need",
144+
"text": _text(need),
145+
"status": need.get("status"),
146+
}
147+
return item
148+
149+
150+
def convert(
151+
needs_data: dict[str, Any],
152+
namespace: str = "req",
153+
up_links: tuple[str, ...] = _DEFAULT_UP_LINKS,
154+
include_types: set[str] | None = None,
155+
version: str | None = None,
156+
) -> dict[str, Any]:
157+
"""Convert a parsed ``needs.json`` document into a lobster-req-trace document."""
158+
version_data = _select_version(needs_data, version)
159+
needs = version_data.get("needs", {})
160+
items = [
161+
need_to_item(need, namespace, up_links)
162+
for need in needs.values()
163+
if include_types is None or need.get("type") in include_types
164+
]
165+
return {
166+
"data": items,
167+
"generator": _GENERATOR,
168+
"schema": _LOBSTER_SCHEMA,
169+
"version": _LOBSTER_VERSION,
170+
}
171+
172+
173+
def _parse_csv(value: str | None) -> tuple[str, ...]:
174+
if not value:
175+
return ()
176+
return tuple(part.strip() for part in value.split(",") if part.strip())
177+
178+
179+
def _build_parser() -> argparse.ArgumentParser:
180+
parser = argparse.ArgumentParser(
181+
description=(
182+
"Convert a sphinx-needs needs.json into a LOBSTER "
183+
"lobster-req-trace (.lobster) file."
184+
)
185+
)
186+
parser.add_argument(
187+
"--needs-json",
188+
required=True,
189+
type=Path,
190+
help="Path to the input needs.json produced by the docs build.",
191+
)
192+
parser.add_argument(
193+
"--output",
194+
type=Path,
195+
default=None,
196+
help="Path to the output .lobster file (default: stdout).",
197+
)
198+
parser.add_argument(
199+
"--namespace",
200+
default="req",
201+
help="LOBSTER tag namespace for the emitted items (default: 'req').",
202+
)
203+
parser.add_argument(
204+
"--up-links",
205+
default=",".join(_DEFAULT_UP_LINKS),
206+
help=(
207+
"Comma-separated sphinx-needs link fields to emit as up-trace refs "
208+
"(default: 'satisfies')."
209+
),
210+
)
211+
parser.add_argument(
212+
"--types",
213+
default=None,
214+
help=(
215+
"Comma-separated need types to include (default: all types). "
216+
"Example: 'tool_req' or 'gd_req'."
217+
),
218+
)
219+
parser.add_argument(
220+
"--version",
221+
default=None,
222+
help="needs.json version key to convert (default: current_version).",
223+
)
224+
return parser
225+
226+
227+
def main(argv: list[str] | None = None) -> int:
228+
args = _build_parser().parse_args(argv)
229+
230+
needs_data = json.loads(args.needs_json.read_text(encoding="utf-8"))
231+
include_types = set(_parse_csv(args.types)) or None
232+
report = convert(
233+
needs_data,
234+
namespace=args.namespace,
235+
up_links=_parse_csv(args.up_links) or _DEFAULT_UP_LINKS,
236+
include_types=include_types,
237+
version=args.version,
238+
)
239+
240+
payload = json.dumps(report, indent=2, sort_keys=True)
241+
if args.output is None:
242+
print(payload)
243+
else:
244+
args.output.write_text(payload + "\n", encoding="utf-8")
245+
print(
246+
f"Wrote {len(report['data'])} requirement item(s) to {args.output}",
247+
file=sys.stderr,
248+
)
249+
return 0
250+
251+
252+
if __name__ == "__main__":
253+
raise SystemExit(main())

scripts_bazel/tests/BUILD

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,12 @@ score_pytest(
4141
] + all_requirements,
4242
pytest_config = "//:pyproject.toml",
4343
)
44+
45+
score_pytest(
46+
name = "needs_to_lobster_test",
47+
srcs = ["needs_to_lobster_test.py"],
48+
deps = [
49+
"//scripts_bazel:needs_to_lobster",
50+
] + all_requirements,
51+
pytest_config = "//:pyproject.toml",
52+
)

0 commit comments

Comments
 (0)