Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion scripts_bazel/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ filegroup(

py_binary(
name = "generate_sourcelinks",
srcs = ["generate_sourcelinks_cli.py"],
srcs = [
"generate_sourcelinks_cli.py",
"source_code_link_parser.py",
],
main = "generate_sourcelinks_cli.py",
visibility = ["//visibility:public"],
deps = [
Expand Down
6 changes: 2 additions & 4 deletions scripts_bazel/generate_sourcelinks_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,7 @@
import sys
from pathlib import Path

from src.extensions.score_source_code_linker.generate_source_code_links_json import (
_extract_references_from_file, # pyright: ignore[reportPrivateUsage] TODO: move it out of the extension and into this script
)
from scripts_bazel.source_code_link_parser import extract_references_from_file
from src.extensions.score_source_code_linker.helpers import parse_repo_name_from_path
from src.extensions.score_source_code_linker.needlinks import (
DefaultMetaData,
Expand Down Expand Up @@ -81,7 +79,7 @@ def main():
abs_file_path = file_path.resolve()
assert abs_file_path.exists(), abs_file_path
clean_path = clean_external_prefix(file_path)
references = _extract_references_from_file(
references = extract_references_from_file(
abs_file_path.parent, Path(abs_file_path.name), clean_path
)
all_need_references.extend(references)
Expand Down
78 changes: 78 additions & 0 deletions scripts_bazel/source_code_link_parser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# *******************************************************************************
# Copyright (c) 2025 Contributors to the Eclipse Foundation
#
# See the NOTICE file(s) distributed with this work for additional
# information regarding copyright ownership.
#
# This program and the accompanying materials are made available under the
# terms of the Apache License 2.0 which is available at
# https://www.apache.org/licenses/LICENSE-2.0
#
# SPDX-License-Identifier: Apache-2.0
# *******************************************************************************

"""Parse source files for traceability tags used by the Bazel link generator."""

import logging
from pathlib import Path

from src.extensions.score_source_code_linker.needlinks import NeedLink

LOGGER = logging.getLogger(__name__)

TAGS = [
"# " + "req-traceability:",
"# " + "req-Id:",
"// " + "req-traceability:",
"// " + "req-Id:",
]


def _extract_references_from_line(line: str):
"""Extract requirement IDs from a line containing a tag."""

for tag in TAGS:
tag_index = line.find(tag)
if tag_index >= 0:
line_after_tag = line[tag_index + len(tag) :].strip()
# Split by comma or space to get multiple requirements
for req in line_after_tag.replace(",", " ").split():
yield tag, req.strip()


def extract_references_from_file(
root: Path, file_path_name: Path, file_path: Path
) -> list[NeedLink]:
"""Scan a single file for traceability tags and return the findings.

``root / file_path_name`` identifies the file to read. ``file_path`` is
the path that should be recorded in the generated source-link data, which
may differ when the input file is located below Bazel's external prefix.
"""

assert root.is_absolute(), "Root path must be absolute"
assert not file_path_name.is_absolute(), "File path must be relative to the root"
assert (root / file_path_name).exists(), (
f"File {file_path_name} does not exist in root {root}."
)

findings: list[NeedLink] = []

try:
with open(root / file_path_name, encoding="utf-8", errors="ignore") as f:
for line_num, line in enumerate(f, 1):
for tag, req in _extract_references_from_line(line):
findings.append(
NeedLink(
file=file_path,
line=line_num,
tag=tag,
need=req,
full_line=line.strip(),
)
)
except (UnicodeDecodeError, PermissionError, OSError) as e:
# Skip files that can't be read as text
LOGGER.debug(f"Error reading file to parse for linked needs: \n{e}")

return findings
2 changes: 1 addition & 1 deletion src/extensions/docs/source_code_linker.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,7 @@ The bazel part:
scripts_bazel/
├── BUILD # Declare libraries and filegroups needed for bazel
├── generate_sourcelinks_cli.py # Bazel step 1 => Parses sourcefiles for tags
├── source_code_link_parser.py # Source-file parser used by the generator
├── merge_sourcelinks.py
└── tests
│ └── ...
Expand All @@ -372,7 +373,6 @@ The Sphinx extension
```text
score_source_code_linker/
├── __init__.py # Main Sphinx extension; combines CodeLinks + TestLinks
├── generate_source_code_links_json.py # Most functionality moved to 'scripts_bazel/generate_sourcelinks_cli'
├── need_source_links.py # Data model for combined links
├── repo_source_links.py # Data model for Repo combined links (Final output JSON)
├── helpers.py # Misc. functions used throughout SCL
Expand Down
105 changes: 28 additions & 77 deletions src/extensions/score_source_code_linker/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,6 @@
from sphinx_needs.logging import get_logger
from sphinx_needs.need_item import NeedItem

from src.extensions.score_source_code_linker.generate_source_code_links_json import (
generate_source_code_links_json,
)
from src.extensions.score_source_code_linker.helpers import get_github_link
from src.extensions.score_source_code_linker.need_source_links import (
group_by_need,
Expand Down Expand Up @@ -63,10 +60,7 @@
construct_and_add_need,
run_xml_parser,
)
from src.helper_lib import (
find_git_root,
find_ws_root,
)
from src.helper_lib import find_ws_root

LOGGER = get_logger(__name__)
# Uncomment this to enable more verbose logging
Expand All @@ -87,29 +81,36 @@ def get_cache_filename(build_dir: Path, filename: str) -> Path:
return build_dir / filename


def build_and_save_combined_file(outdir: Path):
def build_and_save_combined_file(outdir: Path, app: Sphinx | None = None):
"""
Reads the saved partial caches of codelink & testlink
Builds the combined JSON cache & saves it
"""
source_code_links_json = os.environ.get("SCORE_SOURCELINKS")
if not source_code_links_json:
# Fallback to the obsolete way of doing source code links,
# just in case someone is not using the docs(sourcelinks=...) attribute.
# TODO: Remove this once backwards compatibility is not needed anymore.
source_code_links_json = get_cache_filename(
outdir, "score_source_code_linker_cache.json"
)
source_code_links_path = os.environ.get("SCORE_SOURCELINKS")
if not source_code_links_path and app is not None:
source_code_links_path = str(
getattr(app.config, "score_sourcelinks_json", "") or ""
).strip()
Comment thread
AlexanderLanin marked this conversation as resolved.
if source_code_links_path:
source_code_links_json = Path(source_code_links_path)
try:
source_code_links = load_source_code_links_json(source_code_links_json)
except FileNotFoundError as exc:
raise FileNotFoundError(
"Pre-generated source-code links file does not exist: "
f"{source_code_links_json}. Check SCORE_SOURCELINKS or "
"score_sourcelinks_json."
) from exc
except AssertionError:
source_code_links = load_source_code_links_with_metadata_json(
source_code_links_json
)
else:
source_code_links_json = Path(source_code_links_json)

# This isn't pretty will think of a better solution later, for now this should work
try:
source_code_links = load_source_code_links_json(source_code_links_json)
except AssertionError:
source_code_links = load_source_code_links_with_metadata_json(
source_code_links_json
LOGGER.debug(
"No pre-generated source-code links provided. Continuing without code links.",
type="score_source_code_linker",
)
source_code_links = []
test_cache = get_cache_filename(outdir, "score_xml_parser_cache.json")
if test_cache.exists():
test_code_links = load_test_xml_parsed_json(test_cache)
Expand All @@ -130,7 +131,7 @@ def build_and_save_combined_file(outdir: Path):
# ╰──────────────────────────────────────╯


def setup_source_code_linker(app: Sphinx, ws_root: Path | None):
def setup_source_code_linker(app: Sphinx):
"""
Setting up source_code_linker with all needed options.
Allows us to only have this run once during live_preview & esbonio
Expand Down Expand Up @@ -164,42 +165,6 @@ def setup_source_code_linker(app: Sphinx, ws_root: Path | None):
},
)

score_sourcelinks_json = os.environ.get("SCORE_SOURCELINKS")
if not score_sourcelinks_json:
score_sourcelinks_json = str(
getattr(app.config, "score_sourcelinks_json", "")
).strip()
if score_sourcelinks_json:
# Reuse existing code paths that expect this env var.
os.environ["SCORE_SOURCELINKS"] = score_sourcelinks_json
if score_sourcelinks_json:
# No need to generate the JSON file if this env var is set
# because it points to an existing file with the needed data.
return

if ws_root is None:
LOGGER.info(
"No workspace root found and no SCORE_SOURCELINKS provided. "
"Skipping source-code-link scan.",
type="score_source_code_linker",
)
return

scl_cache_json = get_cache_filename(
app.outdir, "score_source_code_linker_cache.json"
)

if (
not scl_cache_json.exists()
or not app.config.skip_rescanning_via_source_code_linker
):
LOGGER.debug(
"INFO: Generating source code links JSON file.",
type="score_source_code_linker",
)

generate_source_code_links_json(ws_root, scl_cache_json)


def register_test_code_linker(app: Sphinx):
# Connects function to sphinx to ensure correct execution order
Expand Down Expand Up @@ -269,7 +234,7 @@ def setup_combined_linker(app: Sphinx, _: BuildEnvironment):
"Did not find combined json 'score_scl_grouped_cache.json' in _build."
"Generating new one"
)
build_and_save_combined_file(app.outdir)
build_and_save_combined_file(app.outdir, app)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We investigated this behavior in detail. The stale-cache observation is technically valid, but it predates this pull request and is unrelated to removing the deprecated source-code-linker cache.

In live preview, incremental.py sets skip_rescanning_via_source_code_linker=1. The existing guards in both setup_combined_linker() and setup_repo_linker() then retain score_scl_grouped_cache.json and score_repo_grouped_scl_cache.json whenever those files already exist. Since _build persists between live-preview runs, changed source-links input can therefore remain unapplied.

Those guards are unchanged on the base branch. PR #803 only removes the extension-side generation/deletion of score_source_code_linker_cache.json; that old file was separate from both derived caches and its deletion never invalidated them. The Bazel-provided SCORE_SOURCELINKS flow was already in place before this PR.

So this is a real cache-lifecycle issue, but not a regression introduced by this cleanup. It would be better handled as a separate follow-up, either by removing the derived JSON handoff entirely and keeping the grouped data in memory, or by adding explicit invalidation for the derived caches.



def register_repo_linker(app: Sphinx):
Expand Down Expand Up @@ -308,26 +273,12 @@ def setup_once(app: Sphinx):
# might be the only way to solve this?
if "skip_rescanning_via_source_code_linker" in app.config:
return
LOGGER.debug(f"DEBUG: Workspace root is {find_ws_root()}")
LOGGER.debug(
f"DEBUG: Current working directory is {Path('.')} = {Path('.').resolve()}"
)
LOGGER.debug(f"DEBUG: Git root is {find_git_root()}")

# Run for local files if possible. In Bazel sandbox builds, ws_root may be
# unavailable; in that case we can still operate when SCORE_SOURCELINKS
# (or score_sourcelinks_json config) is provided.
ws_root = find_ws_root()
if ws_root:
# When BUILD_WORKSPACE_DIRECTORY is set, we are inside a git repository.
assert find_git_root()

# Register & Run (if needed) parsing & saving of JSON caches
# Note: This extension now runs on both internal and external needs_json invocations.
# Both modes aggregate links from local sources and external dependencies, enabling
# unified traceability reporting in integration repositories. Impact on external needs
# invocations is minimal since they typically don't have local test logs or source code.
setup_source_code_linker(app, ws_root)
setup_source_code_linker(app)
register_test_code_linker(app)
register_combined_linker(app)
register_repo_linker(app)
Expand Down
Loading
Loading