diff --git a/.gitignore b/.gitignore index 841eb1b..c6ee9f4 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ invalid_objs.json .tox output/ + +# Claude Code per-user local settings +.claude/settings.local.json diff --git a/docs/source/components/cli.rst b/docs/source/components/cli.rst index 6d1d0bf..4828688 100644 --- a/docs/source/components/cli.rst +++ b/docs/source/components/cli.rst @@ -15,3 +15,41 @@ It features help pages. Add ``-h`` or ``--help`` to any command to see the avail :theme: monokai :show-nested: :make-sections: + +Treating warnings as errors +=========================== + +By default, ``codelinks analyse`` exits ``0`` even when it prints warnings. Pass +``-W`` / ``--strict`` to make it exit ``1`` if any non-suppressed warning was +emitted, mirroring ``sphinx-build -W``. This turns broken markers or missing git +metadata into a hard failure — useful as a CI/CD quality gate. + +.. code-block:: bash + + codelinks analyse codelinks.toml --strict + +The exit-code contract is: + +.. list-table:: + :header-rows: 1 + :widths: 60 20 20 + + * - Situation + - without ``-W`` + - with ``-W`` + * - Completed, no warnings + - ``0`` + - ``0`` + * - Completed, ≥1 non-suppressed warning + - ``0`` + - ``1`` + * - Uncaught exception / crash + - ``1`` + - ``1`` + * - Usage / configuration error + - ``2`` + - ``2`` + +Because ``-W`` covers *all* warnings, expected ones (for example running outside +a git checkout) can be silenced by listing their slugs in +:ref:`suppress_warnings`. Suppressed warnings never count towards the exit code. diff --git a/docs/source/components/configuration.rst b/docs/source/components/configuration.rst index 8da2f2a..afc86f7 100644 --- a/docs/source/components/configuration.rst +++ b/docs/source/components/configuration.rst @@ -112,6 +112,52 @@ Specifies the output directory for generated artifacts such as extracted markers [codelinks] outdir = "output" +.. _`suppress_warnings`: + +suppress_warnings +~~~~~~~~~~~~~~~~~ + +A list of warning *slugs* to silence. The same list is honoured by both the +standalone ``analyse`` command and the Sphinx extension, so suppression is +configured once. + +**Type:** ``list[str]`` +**Default:** ``[]`` + +.. code-block:: toml + + [codelinks] + suppress_warnings = ["codelinks.git", "codelinks.marker.too_many_fields"] + +Slugs are matched hierarchically, so a parent silences everything beneath it: + +- ``codelinks`` — every ``Sphinx-CodeLinks`` warning +- ``codelinks.git`` — the whole git-metadata family +- ``codelinks.git.root`` — just that one warning + +The available slugs are: + +.. list-table:: + :header-rows: 1 + :widths: 20 80 + + * - Family + - Slugs + * - git metadata + - ``codelinks.git.root``, ``codelinks.git.config``, ``codelinks.git.remote``, + ``codelinks.git.head``, ``codelinks.git.ref``, ``codelinks.git.host`` + * - one-line marker + - ``codelinks.marker.too_many_fields``, ``codelinks.marker.too_few_fields``, + ``codelinks.marker.missing_square_brackets``, + ``codelinks.marker.not_start_or_end_with_square_brackets``, + ``codelinks.marker.newline_in_field`` + +Suppressed warnings are dropped entirely: they are neither printed nor counted +towards the ``--strict`` exit code (see :ref:`cli`). In Sphinx the same slugs are +folded into the native ``suppress_warnings``, so ``sphinx-build`` and +``sphinx-build -W`` honour them too; any slugs already set in ``conf.py`` are +preserved. + Project-Specific Options ------------------------ diff --git a/docs/source/development/change_log.rst b/docs/source/development/change_log.rst index 5509b82..13674ae 100644 --- a/docs/source/development/change_log.rst +++ b/docs/source/development/change_log.rst @@ -3,6 +3,29 @@ Changelog ========= +Unreleased +---------- + +New and Improved +................ + +- ✨ Added a ``-W`` / ``--strict`` flag to ``codelinks analyse``. + + With the flag, ``analyse`` exits ``1`` if any non-suppressed warning was + emitted (mirroring ``sphinx-build -W``), so warnings can gate CI/CD pipelines. + Without it, behaviour is unchanged. + +- ✨ Added a shared ``suppress_warnings`` configuration option. + + A single ``[codelinks] suppress_warnings`` list of hierarchical slugs + (e.g. ``codelinks.git`` or ``codelinks.marker.too_many_fields``) silences + warnings for both the CLI and the Sphinx extension. In Sphinx the slugs are + folded into the native ``suppress_warnings``. + +- 👌 Normalised warning slugs under the ``codelinks.git.*`` and + ``codelinks.marker.*`` namespaces (previously ``codelinks.git_*`` and + ``need.*``). + .. _`release:1.3.0`: 1.3.0 diff --git a/docs/src_trace.toml b/docs/src_trace.toml index a439f57..aecefac 100644 --- a/docs/src_trace.toml +++ b/docs/src_trace.toml @@ -4,6 +4,10 @@ set_local_url = true # Set to true to enable local code html and URL local_url_field = "local-url" # Need's field name for local URL set_remote_url = true # Set to true to enable remote url to be generated remote_url_field = "remote-url" # Need's field name for remote URL +# Silence best-effort git-metadata warnings so the strict (-nW) docs build is not +# tripped when building from a git worktree (packed refs). See the CLI docs for +# the -W / suppress_warnings feature this dogfoods. +suppress_warnings = ["codelinks.git"] # Configuration for source tracing project "dcdc" [codelinks.projects.dcdc] diff --git a/src/sphinx_codelinks/analyse/utils.py b/src/sphinx_codelinks/analyse/utils.py index b136be3..1554b87 100644 --- a/src/sphinx_codelinks/analyse/utils.py +++ b/src/sphinx_codelinks/analyse/utils.py @@ -345,7 +345,7 @@ def locate_git_root(src_dir: Path) -> Path | None: return parent logger.warning( f"git root is not found in the parent of {src_dir}", - subtype="git_root", + subtype="git.root", location=str(src_dir), ) return None @@ -357,7 +357,7 @@ def get_remote_url(git_root: Path, remote_name: str = "origin") -> str | None: if not config_path.exists(): logger.warning( f"{config_path} does not exist", - subtype="git_config", + subtype="git.config", location=str(config_path), ) return None @@ -370,7 +370,7 @@ def get_remote_url(git_root: Path, remote_name: str = "origin") -> str | None: return url logger.warning( f"remote-url is not found in {config_path}", - subtype="git_remote", + subtype="git.remote", location=str(config_path), ) return None @@ -382,7 +382,7 @@ def get_current_rev(git_root: Path) -> str | None: if not head_path.exists(): logger.warning( f"{head_path} does not exist", - subtype="git_head", + subtype="git.head", location=str(head_path), ) return None @@ -396,7 +396,7 @@ def get_current_rev(git_root: Path) -> str | None: if not ref_path.exists(): logger.warning( f"{ref_path} does not exist", - subtype="git_ref", + subtype="git.ref", location=str(ref_path), ) return None @@ -411,7 +411,7 @@ def form_https_url( if not template: logger.warning( f"Unsupported Git host: {parsed_url.platform}", - subtype="git_host", + subtype="git.host", ) return git_url https_url = template.format( diff --git a/src/sphinx_codelinks/cmd.py b/src/sphinx_codelinks/cmd.py index f32a30d..3bdf01a 100644 --- a/src/sphinx_codelinks/cmd.py +++ b/src/sphinx_codelinks/cmd.py @@ -14,7 +14,12 @@ CodeLinksProjectConfigType, generate_project_configs, ) -from sphinx_codelinks.logger import configure_cli, logger +from sphinx_codelinks.logger import ( + cli_warning_count, + configure_cli, + get_logger, + set_cli_suppress_warnings, +) from sphinx_codelinks.needextend_write import MarkedObjType, convert_marked_content from sphinx_codelinks.source_discover.config import ( CommentType, @@ -53,10 +58,20 @@ rich_help_panel="Logging", ), ] +OptStrict: TypeAlias = Annotated[ # noqa: UP040 # has to be TypeAlias + bool, + typer.Option( + ..., + "-W", + "--strict", + help="Treat warnings as errors: exit 1 if any non-suppressed warning is emitted", + rich_help_panel="Logging", + ), +] @app.command(no_args_is_help=True) -def analyse( # noqa: PLR0912 # for CLI, so it needs the branches +def analyse( # noqa: PLR0912, PLR0913 # a CLI command: many branches and options config: Annotated[ Path, typer.Argument( @@ -90,6 +105,7 @@ def analyse( # noqa: PLR0912 # for CLI, so it needs the branches ] = None, verbose: OptVerbose = False, quiet: OptQuiet = False, + strict: OptStrict = False, ) -> None: """Analyse marked content in source code.""" # @CLI command to analyse source code and extract traceability markers, IMPL_CLI_ANALYZE, impl, [FE_CLI_ANALYZE] @@ -103,6 +119,11 @@ def analyse( # noqa: PLR0912 # for CLI, so it needs the branches except TypeError as e: raise typer.BadParameter(str(e)) from e + # Apply warning suppression now that the config is known, so both the + # git-metadata warnings (emitted during the run) and the marker warnings + # (emitted below) are dropped and left uncounted at one choke point. + set_cli_suppress_warnings(codelinks_config.suppress_warnings) + errors: deque[str] = deque() if outdir: codelinks_config.outdir = outdir @@ -161,16 +182,25 @@ def analyse( # noqa: PLR0912 # for CLI, so it needs the branches analyse_projects = AnalyseProjects(codelinks_config) analyse_projects.run() - # Output warnings to console for CLI users + # Surface one-line marker warnings through the logging facade so they pass + # the same suppress/count choke point as git-metadata warnings; the slug is + # appended by the backend. + clog = get_logger(__name__) for src_analyse in analyse_projects.projects_analyse.values(): for warning in src_analyse.oneline_warnings: - logger.warning( + clog.warning( f"Oneline parser warning in {warning.file_path}:{warning.lineno} " - f"- {warning.sub_type}: {warning.msg}", + f"- {warning.msg}", + subtype=f"marker.{warning.sub_type}", + location=f"{warning.file_path}:{warning.lineno}", ) analyse_projects.dump_markers() + # Mirror ``sphinx-build -W``: any non-suppressed warning fails the run. + if strict and cli_warning_count() > 0: + raise typer.Exit(code=1) + @app.command(no_args_is_help=True) def discover( # noqa: PLR0913 # CLI command requires multiple parameters diff --git a/src/sphinx_codelinks/config.py b/src/sphinx_codelinks/config.py index f32de43..7ceee83 100644 --- a/src/sphinx_codelinks/config.py +++ b/src/sphinx_codelinks/config.py @@ -662,6 +662,26 @@ def get_schema(cls, name: str) -> dict[str, Any] | None: # type: ignore[explici ) """The field name for the remote URL in the extracted need.""" + suppress_warnings: list[str] = field( + default_factory=list, + metadata={ + "rebuild": "env", + "types": (list,), + "schema": { + "type": "array", + "items": {"type": "string"}, + }, + }, + ) + """Warning slugs to silence, e.g. + ``["codelinks.git", "codelinks.marker.too_many_fields"]``. + + Matched hierarchically: ``codelinks`` silences everything, ``codelinks.git`` + the whole git-metadata family, ``codelinks.git.root`` just one. Honoured by + the standalone ``analyse`` command (matching warnings are dropped and not + counted for ``--strict``) and folded into Sphinx's native + ``suppress_warnings`` for the extension.""" + outdir: Path = field( default=Path("output"), metadata={"rebuild": "env", "types": (str), "schema": {"type": "string"}}, diff --git a/src/sphinx_codelinks/logger.py b/src/sphinx_codelinks/logger.py index f100455..3880a9d 100644 --- a/src/sphinx_codelinks/logger.py +++ b/src/sphinx_codelinks/logger.py @@ -1,3 +1,4 @@ +from collections.abc import Iterable import logging from typing import Protocol @@ -117,6 +118,62 @@ def error( # --------------------------------------------------------------------------- # +# Every warning the ``analyse`` layer can emit, as its fully-qualified slug +# (``codelinks..``). The single source of truth used to expand +# hierarchical ``suppress_warnings`` entries for Sphinx's flat matcher. +CODELINKS_WARNING_SLUGS: tuple[str, ...] = ( + "codelinks.git.root", + "codelinks.git.config", + "codelinks.git.remote", + "codelinks.git.head", + "codelinks.git.ref", + "codelinks.git.host", + "codelinks.marker.too_many_fields", + "codelinks.marker.too_few_fields", + "codelinks.marker.missing_square_brackets", + "codelinks.marker.not_start_or_end_with_square_brackets", + "codelinks.marker.newline_in_field", +) + + +def is_suppressed(slug: str, patterns: Iterable[str]) -> bool: + """Return whether ``slug`` is silenced by any of ``patterns``. + + Matching is hierarchical on dot boundaries: a parent silences everything + below it (``codelinks`` -> all, ``codelinks.git`` -> the git family, + ``codelinks.git.root`` -> just that one). A trailing ``.*`` is accepted as + a Sphinx-style alias for the bare parent. ``codelinks.git`` never matches + ``codelinks.github`` because matching respects the ``.`` separator. + """ + for pattern in patterns: + parent = pattern.removesuffix(".*") + if slug == parent or slug.startswith(f"{parent}."): + return True + return False + + +def expand_suppress_for_sphinx(entries: Iterable[str]) -> list[str]: + """Expand hierarchical codelinks slugs into the flat tokens Sphinx's + ``suppress_warnings`` matcher understands. + + Sphinx only matches an exact ``type`` / ``type.subtype`` or a ``type.*`` + wildcard, so a family entry like ``codelinks.git`` would silence nothing on + its own. Each entry therefore keeps itself and gains every registry slug it + covers, so a family or the top-level entry silences its members under Sphinx + too. Non-codelinks entries pass through unchanged; order is stable and + duplicates are removed. + """ + expanded: list[str] = [] + seen: set[str] = set() + for entry in entries: + covered = [s for s in CODELINKS_WARNING_SLUGS if is_suppressed(s, [entry])] + for token in (entry, *covered): + if token not in seen: + seen.add(token) + expanded.append(token) + return expanded + + class _Backend(Protocol): """Where the ``analyse`` layer's log records are routed. @@ -157,8 +214,16 @@ class _CliBackend: The summary is INFO (stdout, hidden by ``--quiet``); the breakdown is DEBUG (stdout, shown only with ``--verbose``); warnings go to stderr. + + This is also the single choke point where standalone warnings are dropped + by ``suppress_warnings`` and the surviving ones counted, so ``--strict`` + can fail the run on any non-suppressed warning. """ + def __init__(self, suppress_warnings: Iterable[str] = ()) -> None: + self.suppress_warnings: tuple[str, ...] = tuple(suppress_warnings) + self.warning_count = 0 + def debug(self, _name: str, msg: str, _location: str | None, /) -> None: logger.debug(msg) @@ -166,11 +231,16 @@ def info(self, _name: str, msg: str, _location: str | None, /) -> None: logger.info(msg) def warning( - self, _name: str, msg: str, _subtype: str, _location: str | None, / + self, _name: str, msg: str, subtype: str, _location: str | None, / ) -> None: + slug = f"codelinks.{subtype}" if subtype else "codelinks" + if is_suppressed(slug, self.suppress_warnings): + return + self.warning_count += 1 # reserve stderr for warnings/errors (the rich logger prints to stdout - # by default; route this to the error console explicitly) - logger.warning(msg, console=logger.err_console) + # by default; route this to the error console explicitly). The slug is + # appended so users know what to add to ``suppress_warnings``. + logger.warning(f"{msg} [{slug}]", console=logger.err_console) class _SphinxBackend: @@ -245,10 +315,31 @@ def get_logger(name: str) -> CodelinksLogger: return CodelinksLogger(name) -def configure_cli(verbose: bool = False, quiet: bool = False) -> None: +def configure_cli( + verbose: bool = False, + quiet: bool = False, + suppress_warnings: Iterable[str] = (), +) -> None: """Select the CLI frontend and configure the rich logger's verbosity.""" logger.configure(verbose=verbose, quiet=quiet) - _dispatch.backend = _CliBackend() + _dispatch.backend = _CliBackend(suppress_warnings) + + +def set_cli_suppress_warnings(patterns: Iterable[str]) -> None: + """Set the CLI suppression list on the active backend. + + The standalone CLI configures logging before it has parsed the TOML config, + so the suppression list is applied here once it is known. No-op unless the + CLI backend is active. + """ + if isinstance(_dispatch.backend, _CliBackend): + _dispatch.backend.suppress_warnings = tuple(patterns) + + +def cli_warning_count() -> int: + """Number of non-suppressed warnings emitted through the CLI backend.""" + backend = _dispatch.backend + return backend.warning_count if isinstance(backend, _CliBackend) else 0 def configure_sphinx() -> None: diff --git a/src/sphinx_codelinks/sphinx_extension/source_tracing.py b/src/sphinx_codelinks/sphinx_extension/source_tracing.py index ba9e736..f8b6719 100644 --- a/src/sphinx_codelinks/sphinx_extension/source_tracing.py +++ b/src/sphinx_codelinks/sphinx_extension/source_tracing.py @@ -26,7 +26,7 @@ file_lineno_href, generate_project_configs, ) -from sphinx_codelinks.logger import configure_sphinx +from sphinx_codelinks.logger import configure_sphinx, expand_suppress_for_sphinx from sphinx_codelinks.sphinx_extension import debug from sphinx_codelinks.sphinx_extension.directives.src_trace import ( SourceTracing, @@ -104,6 +104,9 @@ def setup(app: Sphinx) -> dict[str, Any]: # type: ignore[explicit-any] app.connect( "config-inited", update_sn_extra_options, priority=11 ) # run early otherwise, extra options are not set for nested_parse + app.connect( + "config-inited", fold_suppress_warnings, priority=12 + ) # after the TOML config has populated src_trace_suppress_warnings app.connect("config-inited", update_sn_types) app.connect("config-inited", check_sphinx_configuration) @@ -212,6 +215,23 @@ def set_config_to_sphinx( config[f"src_trace_{key}"] = value +def fold_suppress_warnings(_app: Sphinx, config: _SphinxConfig) -> None: + """Fold the shared codelinks ``suppress_warnings`` into Sphinx's native one. + + A single ``[codelinks] suppress_warnings`` list drives both frontends. Here + it is merged into Sphinx's native ``suppress_warnings`` so ``sphinx-build`` + and ``sphinx-build -W`` honour it. Hierarchical slugs are expanded for + Sphinx's flat matcher, and the merge is additive so any ``conf.py`` entry is + preserved. Runs after :func:`load_config_from_toml` (which populates + ``src_trace_suppress_warnings``). + """ + extra = expand_suppress_for_sphinx(config["src_trace_suppress_warnings"]) + if not extra: + return + existing = list(config["suppress_warnings"]) + config["suppress_warnings"] = existing + [x for x in extra if x not in existing] + + def update_sn_extra_options(app: Sphinx, config: _SphinxConfig) -> None: src_trace_sphinx_config = CodeLinksConfig.from_sphinx(config) _register_sn_field(app, "project", "Source-tracing project") @@ -264,6 +284,6 @@ def emit_warnings( for warning in warnings: logger.warning( f"{warning.file_path}:{warning.lineno}: {warning.msg}", - type=warning.type, - subtype=warning.sub_type, + type="codelinks", + subtype=f"marker.{warning.sub_type}", ) diff --git a/tests/test_analyse_config.py b/tests/test_analyse_config.py index 8ced200..1835f1b 100644 --- a/tests/test_analyse_config.py +++ b/tests/test_analyse_config.py @@ -1,7 +1,12 @@ # @Test suite for source analysis configuration validation, TEST_CONF_1, test, [IMPL_OLP_1] import pytest -from sphinx_codelinks.config import OneLineCommentStyle, SourceAnalyseConfig +from sphinx_codelinks.config import ( + CodeLinksConfig, + OneLineCommentStyle, + SourceAnalyseConfig, + check_schema, +) from .conftest import TEST_DIR @@ -209,3 +214,25 @@ def test_oneline_schema_validator_negative(oneline_config, result): ) def test_oneline_schema_validator_positive(oneline_config): assert len(oneline_config.check_fields_configuration()) == 0 + + +def test_suppress_warnings_is_a_config_field_defaulting_empty(): + assert "suppress_warnings" in CodeLinksConfig.field_names() + assert CodeLinksConfig().suppress_warnings == [] + + +def test_suppress_warnings_accepts_a_list_of_slugs(): + config = CodeLinksConfig( + suppress_warnings=["codelinks.git", "codelinks.marker.too_many_fields"] + ) + assert config.suppress_warnings == [ + "codelinks.git", + "codelinks.marker.too_many_fields", + ] + assert check_schema(config) == [] + + +def test_suppress_warnings_schema_rejects_non_list(): + config = CodeLinksConfig(suppress_warnings="codelinks.git") # must be a list + errors = check_schema(config) + assert any("suppress_warnings" in error for error in errors) diff --git a/tests/test_cmd.py b/tests/test_cmd.py index d6533e4..01ce51f 100644 --- a/tests/test_cmd.py +++ b/tests/test_cmd.py @@ -116,6 +116,129 @@ def test_analyse_outputs_warnings(tmp_path: Path) -> None: assert "too_many_fields" in result.output +def _oneline_warning_config(tmp_path: Path, **extra: object) -> Path: + """Write a config whose default one-line style makes the sample file warn.""" + src_dir = TEST_DIR / "data" / "oneline_comment_default" + config_dict: dict = { + "codelinks": { + "outdir": str(tmp_path), + "projects": { + "test_project": { + "source_discover": { + "src_dir": str(src_dir), + "include": ["*.c"], + "comment_type": "cpp", + }, + "analyse": {"get_oneline_needs": True}, + } + }, + **extra, + } + } + config_file = tmp_path / "test_config.toml" + with config_file.open("w", encoding="utf-8") as f: + toml.dump(config_dict, f) + return config_file + + +def test_analyse_warnings_carry_codelinks_marker_slug(tmp_path: Path) -> None: + """Marker warnings surface with their ``codelinks.marker.`` slug so + they can be suppressed and counted like every other warning.""" + config_file = _oneline_warning_config(tmp_path) + + result = runner.invoke(app, ["analyse", str(config_file)]) + + assert result.exit_code == 0 + assert "codelinks.marker.too_many_fields" in result.output + + +def test_analyse_without_strict_exits_zero_despite_warnings(tmp_path: Path) -> None: + """Default behavior is unchanged: warnings are printed but never fail.""" + config_file = _oneline_warning_config(tmp_path) + result = runner.invoke(app, ["analyse", str(config_file)]) + assert result.exit_code == 0 + + +@pytest.mark.parametrize("flag", ["-W", "--strict"]) +def test_analyse_strict_exits_one_on_a_surviving_warning( + flag: str, tmp_path: Path +) -> None: + """--strict turns a surviving warning into exit 1, like ``sphinx-build -W``. + Git warnings are suppressed so the marker warning is the deterministic + trigger regardless of the checkout's git layout.""" + config_file = _oneline_warning_config(tmp_path, suppress_warnings=["codelinks.git"]) + result = runner.invoke(app, ["analyse", str(config_file), flag]) + assert result.exit_code == 1 + + +def test_analyse_strict_passes_when_the_warning_is_suppressed(tmp_path: Path) -> None: + """An exact-leaf suppression of the only warning makes --strict pass.""" + config_file = _oneline_warning_config( + tmp_path, + suppress_warnings=["codelinks.git", "codelinks.marker.too_many_fields"], + ) + result = runner.invoke(app, ["analyse", str(config_file), "-W"]) + assert result.exit_code == 0 + + +def test_analyse_strict_still_fails_when_a_different_slug_is_suppressed( + tmp_path: Path, +) -> None: + """Suppression is precise: silencing an unrelated leaf leaves the real one.""" + config_file = _oneline_warning_config( + tmp_path, + suppress_warnings=["codelinks.git", "codelinks.marker.too_few_fields"], + ) + result = runner.invoke(app, ["analyse", str(config_file), "-W"]) + assert result.exit_code == 1 + + +def _empty_git_repo_config(tmp_path: Path, suppress: list[str] | None = None) -> Path: + """A config whose explicit git_root has an empty ``.git`` (forcing git.* + warnings) over a marker-free source (no marker warnings) — a deterministic + way to exercise the git-warning arm of --strict.""" + repo = tmp_path / "repo" + (repo / ".git").mkdir(parents=True) + src_dir = repo / "src" + src_dir.mkdir() + (src_dir / "plain.c").write_text("void f(void) {}\n") + (tmp_path / "out").mkdir() + codelinks: dict = { + "outdir": str(tmp_path / "out"), + "projects": { + "p": { + "source_discover": { + "src_dir": str(src_dir), + "gitignore": False, + "include": ["*.c"], + "comment_type": "cpp", + }, + "analyse": {"get_oneline_needs": True, "git_root": str(repo)}, + } + }, + } + if suppress is not None: + codelinks["suppress_warnings"] = suppress + config_file = tmp_path / "git_config.toml" + with config_file.open("w", encoding="utf-8") as f: + toml.dump({"codelinks": codelinks}, f) + return config_file + + +def test_analyse_strict_exits_one_on_a_git_warning(tmp_path: Path) -> None: + config_file = _empty_git_repo_config(tmp_path) + result = runner.invoke(app, ["analyse", str(config_file), "-W"]) + assert result.exit_code == 1 + + +def test_analyse_strict_git_family_suppression_clears_git_warnings( + tmp_path: Path, +) -> None: + config_file = _empty_git_repo_config(tmp_path, suppress=["codelinks.git"]) + result = runner.invoke(app, ["analyse", str(config_file), "-W"]) + assert result.exit_code == 0 + + def test_analyse_logs_per_project_summary_and_gates_detail(tmp_path: Path) -> None: """Each project gets a default-visible ``codelinks []`` summary with counts; the per-type breakdown is gated behind --verbose; --quiet silences it.""" diff --git a/tests/test_logger.py b/tests/test_logger.py index dd43cfb..ad7af85 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -6,6 +6,7 @@ from sphinx.util.logging import VERBOSE from sphinx_codelinks import logger as logmod +from sphinx_codelinks.analyse import utils @pytest.fixture(autouse=True) @@ -24,7 +25,7 @@ def test_default_backend_drops_info_and_emits_warning(caplog): log = logmod.get_logger("sphinx_codelinks.analyse.sample") log.info("routine progress") - log.warning("real problem", subtype="git_root") + log.warning("real problem", subtype="git.root") messages = [record.getMessage() for record in caplog.records] assert "routine progress" not in messages @@ -37,7 +38,7 @@ def test_cli_backend_routes_info_to_stdout_and_warning_to_stderr(capsys): log = logmod.get_logger("sphinx_codelinks.analyse.sample") log.info("files loaded: 3") - log.warning("git root not found", subtype="git_root") + log.warning("git root not found", subtype="git.root") captured = capsys.readouterr() assert "files loaded: 3" in captured.out @@ -52,7 +53,7 @@ def test_cli_backend_quiet_suppresses_info_but_keeps_warning(capsys): log = logmod.get_logger("sphinx_codelinks.analyse.sample") log.info("files loaded: 3") - log.warning("git root not found", subtype="git_root") + log.warning("git root not found", subtype="git.root") captured = capsys.readouterr() assert "files loaded: 3" not in captured.out @@ -93,7 +94,7 @@ def test_sphinx_backend_routes_through_sphinx_logging(): log = logmod.get_logger("sphinx_codelinks.analyse.sample") log.info("project summary") log.debug("breakdown detail") - log.warning("git root not found", subtype="git_root", location="x.cpp") + log.warning("git root not found", subtype="git.root", location="x.cpp") finally: sphinx_logger.removeHandler(handler) sphinx_logger.setLevel(old_level) @@ -115,7 +116,7 @@ def test_sphinx_backend_routes_through_sphinx_logging(): assert warn_records assert warn_records[0].levelno == logging.WARNING assert getattr(warn_records[0], "type", None) == "codelinks" - assert getattr(warn_records[0], "subtype", None) == "git_root" + assert getattr(warn_records[0], "subtype", None) == "git.root" ANALYSE_MODULE_LOGGERS = ( @@ -138,3 +139,187 @@ def test_analyse_modules_install_no_handlers_at_import(): assert module_logger.level == logging.NOTSET, ( f"{name} pinned its level at import to {module_logger.level}" ) + + +def test_registry_lists_all_warning_slugs(): + """The canonical registry names every warning slug analyse can emit.""" + assert logmod.CODELINKS_WARNING_SLUGS == ( + "codelinks.git.root", + "codelinks.git.config", + "codelinks.git.remote", + "codelinks.git.head", + "codelinks.git.ref", + "codelinks.git.host", + "codelinks.marker.too_many_fields", + "codelinks.marker.too_few_fields", + "codelinks.marker.missing_square_brackets", + "codelinks.marker.not_start_or_end_with_square_brackets", + "codelinks.marker.newline_in_field", + ) + + +def test_cli_backend_counts_non_suppressed_warnings(capsys): + """The CLI backend is the single choke point that counts warnings and + surfaces each slug so users know what to suppress.""" + logmod.configure_cli() + log = logmod.get_logger("sphinx_codelinks.analyse.sample") + + log.warning("git root not found", subtype="git.root") + log.warning("too many fields", subtype="marker.too_many_fields") + + err = capsys.readouterr().err + assert logmod.cli_warning_count() == 2 + assert "git root not found" in err + assert "codelinks.git.root" in err + assert "codelinks.marker.too_many_fields" in err + + +def test_cli_backend_drops_suppressed_warnings(capsys): + """A suppressed warning is neither printed nor counted.""" + logmod.configure_cli(suppress_warnings=["codelinks.git"]) + log = logmod.get_logger("sphinx_codelinks.analyse.sample") + + log.warning("git root not found", subtype="git.root") + log.warning("too many fields", subtype="marker.too_many_fields") + + err = capsys.readouterr().err + assert logmod.cli_warning_count() == 1 + assert "git root not found" not in err + assert "too many fields" in err + + +def test_cli_backend_without_subtype_uses_bare_codelinks_slug(capsys): + """A warning without a subtype still counts and is suppressible via the + bare ``codelinks`` slug.""" + logmod.configure_cli(suppress_warnings=["codelinks"]) + logmod.get_logger("x").warning("no subtype") + assert logmod.cli_warning_count() == 0 + assert capsys.readouterr().err == "" + + +def test_set_cli_suppress_warnings_updates_active_backend(capsys): + """The suppression list can be set after the backend is configured (the + CLI learns it only once the TOML config is loaded).""" + logmod.configure_cli() + logmod.set_cli_suppress_warnings(["codelinks"]) + logmod.get_logger("x").warning("anything", subtype="git.root") + + assert logmod.cli_warning_count() == 0 + assert capsys.readouterr().err == "" + + +def test_cli_warning_count_is_zero_without_a_cli_backend(): + """Querying the count under the default/library backend is safe.""" + logmod.reset() + assert logmod.cli_warning_count() == 0 + + +@pytest.mark.parametrize( + ("slug", "patterns", "expected"), + [ + # a bare parent silences the whole hierarchy below it + ("codelinks.git.root", ["codelinks"], True), + ("codelinks.marker.too_many_fields", ["codelinks"], True), + # family level silences only that family + ("codelinks.git.root", ["codelinks.git"], True), + ("codelinks.git.host", ["codelinks.git"], True), + ("codelinks.marker.too_many_fields", ["codelinks.git"], False), + # matching is on dot boundaries, never a raw string prefix + ("codelinks.github", ["codelinks.git"], False), + # exact leaf silences only that leaf + ("codelinks.git.root", ["codelinks.git.root"], True), + ("codelinks.git.host", ["codelinks.git.root"], False), + # a trailing ".*" is accepted as a Sphinx-style alias for the parent + ("codelinks.git.root", ["codelinks.git.*"], True), + ("codelinks.marker.newline_in_field", ["codelinks.*"], True), + # no patterns suppress nothing + ("codelinks.git.root", [], False), + # any matching pattern wins + ( + "codelinks.marker.newline_in_field", + ["codelinks.git", "codelinks.marker"], + True, + ), + ], +) +def test_is_suppressed(slug, patterns, expected): + assert logmod.is_suppressed(slug, patterns) is expected + + +@pytest.mark.parametrize( + ("entries", "expected"), + [ + # a family expands to its concrete leaves (plus itself), so Sphinx's + # flat matcher can silence the whole family + ( + ["codelinks.git"], + [ + "codelinks.git", + "codelinks.git.root", + "codelinks.git.config", + "codelinks.git.remote", + "codelinks.git.head", + "codelinks.git.ref", + "codelinks.git.host", + ], + ), + # an exact leaf stays a single token + (["codelinks.git.root"], ["codelinks.git.root"]), + # the top-level expands to itself plus every known slug + (["codelinks"], ["codelinks", *logmod.CODELINKS_WARNING_SLUGS]), + # a non-codelinks entry passes through unchanged + (["ref.term"], ["ref.term"]), + # duplicates across entries are removed, order preserved + ( + ["codelinks.git.root", "codelinks.git"], + [ + "codelinks.git.root", + "codelinks.git", + "codelinks.git.config", + "codelinks.git.remote", + "codelinks.git.head", + "codelinks.git.ref", + "codelinks.git.host", + ], + ), + ([], []), + ], +) +def test_expand_suppress_for_sphinx(entries, expected): + assert logmod.expand_suppress_for_sphinx(entries) == expected + + +def test_git_metadata_warnings_use_dotted_codelinks_slugs(tmp_path, capsys): + """Every git-metadata warning surfaces under ``codelinks.git.`` so it + is suppressible with the same hierarchical slugs as marker warnings.""" + logmod.configure_cli() + + # git.root: no .git anywhere above the directory + utils.locate_git_root(tmp_path / "no_repo") + + repo = tmp_path / "repo" + (repo / ".git").mkdir(parents=True) + # git.config: .git exists but no config file + utils.get_remote_url(repo) + # git.remote: config present but no remote url + (repo / ".git" / "config").write_text("[core]\n") + utils.get_remote_url(repo) + # git.head: no .git/HEAD + utils.get_current_rev(repo) + # git.ref: HEAD points at a ref file that does not exist + (repo / ".git" / "HEAD").write_text("ref: refs/heads/main\n") + utils.get_current_rev(repo) + # git.host: unsupported git hosting platform + utils.form_https_url("git@bitbucket.org:o/r.git", "rev", repo, repo / "f.c", 1) + + err = capsys.readouterr().err + for slug in ( + "codelinks.git.root", + "codelinks.git.config", + "codelinks.git.remote", + "codelinks.git.head", + "codelinks.git.ref", + "codelinks.git.host", + ): + assert slug in err, f"missing {slug} in:\n{err}" + assert logmod.cli_warning_count() == 6 diff --git a/tests/test_src_trace.py b/tests/test_src_trace.py index b339055..8d7dd52 100644 --- a/tests/test_src_trace.py +++ b/tests/test_src_trace.py @@ -239,6 +239,41 @@ def test_build_html( assert app.env.get_doctree("index") == snapshot_doctree +def test_suppress_warnings_folded_into_sphinx_native( + tmpdir: Path, + make_app: Callable[..., SphinxTestApp], +) -> None: + """The shared ``[codelinks] suppress_warnings`` is folded into Sphinx's + native ``suppress_warnings`` (expanded for its flat matcher) and stays + additive with any ``conf.py`` entry.""" + this_file_dir = Path(__file__).parent + project = Path("doc_test") / "minimum_config" + sphinx_src_dir = Path(tmpdir) / project + shutil.copytree(this_file_dir / project, sphinx_src_dir, dirs_exist_ok=True) + + # a native conf.py entry must survive the fold (stays additive) + conf_py = sphinx_src_dir / "conf.py" + conf_py.write_text(conf_py.read_text() + '\nsuppress_warnings = ["ref.term"]\n') + + # the shared codelinks list, expressed hierarchically + (sphinx_src_dir / "src_trace.toml").write_text( + "[codelinks]\n" + 'suppress_warnings = ["codelinks.git", "codelinks.marker.too_many_fields"]\n\n' + "[codelinks.projects.src]\n" + "remote_url_pattern = " + '"https://github.com/useblocks/sphinx-codelinks/blob/{commit}/{path}#L{line}"\n' + ) + + app = make_app(srcdir=sphinx_src_dir, freshenv=True) + app.build() + + suppressed = app.config.suppress_warnings + assert "ref.term" in suppressed # conf.py entry preserved + assert "codelinks.git.root" in suppressed # family expanded to concrete slugs + assert "codelinks.git.host" in suppressed + assert "codelinks.marker.too_many_fields" in suppressed # exact leaf kept + + def test_incremental_build_keeps_src_trace_projects_unchanged( tmpdir: Path, make_app: Callable[..., SphinxTestApp],