[docs]  1# @Test suite for sources outside the roots their records are relative to, TEST_OUTSIDE_ROOTS_1, test, [IMPL_DISC_1]
  2"""A source file outside the root its records are relative to is diagnosed (#2062).
  3
  4Two configurations put one there: a configured ``git_root`` that does not contain the
  5project's ``src_dir`` (or does not exist), which is ignored with one
  6``codelinks.git_root`` warning, the repository root being detected from ``src_dir``
  7instead; and a discovered file that resolves to outside ``src_dir`` (a symbolic link out
  8of the tree), which is not traced, with one ``codelinks.outside_src_dir`` warning
  9naming the link. Every build case copies ``doc_test/need_id_refs`` (see
 10``test_need_id_refs``).
 11"""
 12
 13import json
 14import ntpath
 15import os
 16import posixpath
 17import re
 18import subprocess
 19import sys
 20from pathlib import Path, PurePosixPath, PureWindowsPath
 21from types import SimpleNamespace
 22from typing import Any
 23
 24import pytest
 25import sphinx
 26from sphinx.testing.util import SphinxTestApp
 27from typer.testing import CliRunner
 28
 29from sphinx_codelinks.analyse.references import NeedIdRef
 30from sphinx_codelinks.cmd import app as cli
 31from sphinx_codelinks.config import CodeLinksConfig, git_root_problem
 32from sphinx_codelinks.source_discover import source_discover
 33from sphinx_codelinks.source_discover.config import SourceDiscoverConfig
 34from sphinx_codelinks.sphinx_extension.need_id_refs import (
 35    need_id_refs_store,
 36    project_roots,
 37)
 38from sphinx_codelinks.sphinx_extension.rediscovery import discover_scope
 39from sphinx_needs_testkit import build_warnings
 40
 41from .test_need_id_refs import (
 42    DANGLING,
 43    FIXTURE,
 44    _build,
 45    _json,
 46    _MakeApp,
 47    _project,
 48    _refs,
 49    _url,
 50)
 51
 52#: Sphinx 8 renders a warning's ``[type.subtype]`` itself; 7.4 does not
 53_SHOWS_WARNING_TYPES = sphinx.version_info >= (8,)
 54
 55INDEX = (FIXTURE / "docs" / "index.rst").read_text(encoding="utf-8")
 56DIRECTIVE = ".. src-trace::\n   :project: src\n"
 57assert DIRECTIVE in INDEX
 58
 59#: the file the links point at: a one-line need and a reference, so that a traced
 60#: copy of it shows in the needs and in ``REQ_002``'s references alike
 61OUTSIDE_SOURCE = "// @outside need, IMPL_OUTSIDE\n// @need-ids: REQ_002\n"
 62
 63LOCAL_ONLY = ("set_remote_url = true", "set_remote_url = false")
 64REMOTE_ONLY = ("set_local_url = true", "set_local_url = false")
 65
 66
 67#: what every ``codelinks.git_root`` warning says (Sphinx 7.4 renders no type suffix)
 68IGNORED = "it is ignored, and the repository root is detected from src_dir instead"
 69
 70
 71def _git_root(value: str) -> str:
 72    return f'\n[codelinks.projects.src.analyse]\ngit_root = "{value}"\n'
 73
 74
 75def _git_root_warning(root: Path, problem: str) -> str:
 76    suffix = " [codelinks.git_root]" if _SHOWS_WARNING_TYPES else ""
 77    return (
 78        f"WARNING: project 'src': git_root {root.as_posix()} {problem}; it is "
 79        "ignored, and the repository root is detected from src_dir instead"
 80        f"{suffix}"
 81    )
 82
 83
 84def _outside_warning(link: Path, target: Path, src_dir: Path) -> str:
 85    """The warning about ``link``, which leads it (the package logger appends the
 86    type on Sphinx 7)."""
 87    return (
 88        f"WARNING: {link.as_posix()} resolves to "
 89        f"{target.as_posix()}, outside src_dir {src_dir.as_posix()}: not traced "
 90        "(widen src_dir to cover it, or exclude the link) [codelinks.outside_src_dir]"
 91    )
 92
 93
 94def _records(app: SphinxTestApp) -> list[NeedIdRef]:
 95    """Every directive's records, in file and line order."""
 96    records = [ref for refs in need_id_refs_store(app.env).values() for ref in refs]
 97    return sorted(records, key=lambda ref: (ref.path, ref.lineno, ref.need_id))
 98
 99
100def _link_out(root: Path, link: str, target: str, *, directory: bool = False) -> None:
101    """Make ``root/link`` a symbolic link to ``root/target``."""
102    (root / link).symlink_to(root / target, target_is_directory=directory)
103
104
105# -- a git_root that does not contain src_dir ----------------------------------------
106
107
108def test_git_root_outside_src_dir_is_ignored_with_a_warning(
109    tmp_path: Path, make_app: _MakeApp
110) -> None:
111    """``git_root = "../other"`` exists but holds no source: one warning naming both
112    paths, and the records are relative to the repository detected from ``src_dir``
113    -- no ``../``, ``root == "git"``, and each round-trips through its dictionary."""
114    (tmp_path / "other").mkdir()
115    _project(tmp_path, toml_replace=LOCAL_ONLY, toml_extra=_git_root("../other"))
116    app = _build(tmp_path, make_app)
117
118    root = tmp_path.resolve()
119    assert build_warnings(app) == [
120        _git_root_warning(
121            root / "other", f"does not contain src_dir {(root / 'src').as_posix()}"
122        ),
123        DANGLING,
124    ]
125    records = _records(app)
126    assert records
127    assert {ref.path for ref in records} == {"src/refs.cpp"}
128    assert {ref.root for ref in records} == {"git"}
129    assert [NeedIdRef.from_dict(ref.to_dict()) for ref in records] == records
130    assert _refs(app)["REQ_001"] == ["src/refs.cpp#L1", "src/refs.cpp#L3"]
131
132
133def test_git_root_outside_src_dir_with_remote_urls_uses_the_detected_root(
134    tmp_path: Path, make_app: _MakeApp
135) -> None:
136    """With remote URLs on the remote path is no longer a traceback: the URLs are
137    formed below the repository detected from ``src_dir``."""
138    (tmp_path / "other").mkdir()
139    commit = _project(tmp_path, toml_extra=_git_root("../other"))
140    app = _build(tmp_path, make_app)
141
142    root = tmp_path.resolve()
143    assert build_warnings(app) == [
144        _git_root_warning(
145            root / "other", f"does not contain src_dir {(root / 'src').as_posix()}"
146        ),
147        DANGLING,
148    ]
149    refs = _refs(app)
150    assert refs["REQ_001"] == [_url(commit, 1), _url(commit, 3)]
151    assert refs["REQ_003"] == [_url(commit, 7)]
152
153
154def test_missing_git_root_is_ignored_with_a_warning(
155    tmp_path: Path, make_app: _MakeApp
156) -> None:
157    """A ``git_root`` naming no directory is the same warning class, and the build
158    goes on with the detected root."""
159    commit = _project(tmp_path, toml_extra=_git_root("../missing"))
160    app = _build(tmp_path, make_app)
161
162    assert build_warnings(app) == [
163        _git_root_warning(tmp_path.resolve() / "missing", "does not exist"),
164        DANGLING,
165    ]
166    assert _refs(app)["REQ_001"] == [_url(commit, 1), _url(commit, 3)]
167    assert {ref.path for ref in _records(app)} == {"src/refs.cpp"}
168
169
170def test_the_attach_root_is_the_records_root(
171    tmp_path: Path, make_app: _MakeApp
172) -> None:
173    """The root the attach dedupes on is the one the records were made relative to:
174    both ignore the same ``git_root``."""
175    (tmp_path / "other").mkdir()
176    _project(tmp_path, toml_replace=LOCAL_ONLY, toml_extra=_git_root("../other"))
177    app = _build(tmp_path, make_app)
178
179    roots = project_roots(app.confdir, CodeLinksConfig.from_sphinx(app.config))
180    assert roots == {"src": tmp_path.resolve().as_posix()}
181
182
183@pytest.mark.parametrize("accepted", ["src_dir", "above_the_repository"])
184def test_an_accepted_git_root_is_the_records_and_the_attachs_root(
185    tmp_path: Path, make_app: _MakeApp, accepted: str
186) -> None:
187    """A ``git_root`` other than the detected repository -- ``src_dir`` itself, or a
188    directory above the repository that is no repository -- is used by the analysis
189    AND by the attach: the records are relative to it, and ``project_roots()`` names
190    it."""
191    project = tmp_path / "proj"
192    project.mkdir()
193    value, root, path = {
194        "src_dir": ("../src", project / "src", "refs.cpp"),
195        "above_the_repository": (tmp_path.as_posix(), tmp_path, "proj/src/refs.cpp"),
196    }[accepted]
197    _project(project, toml_replace=LOCAL_ONLY, toml_extra=_git_root(value))
198    app = _build(project, make_app)
199
200    assert not any(IGNORED in w for w in build_warnings(app))
201    records = _records(app)
202    assert records
203    assert {ref.path for ref in records} == {path}
204    assert {ref.root for ref in records} == {"git"}
205    roots = project_roots(app.confdir, CodeLinksConfig.from_sphinx(app.config))
206    assert roots == {"src": root.resolve().as_posix()}
207    assert (Path(roots["src"]) / path).resolve() == (
208        project / "src" / "refs.cpp"
209    ).resolve()
210
211
212@pytest.mark.skipif(
213    sys.platform == "win32" or (hasattr(os, "geteuid") and os.geteuid() == 0),
214    reason="needs POSIX permissions that bind the running user",
215)
216def test_an_unreadable_git_root_is_ignored_with_a_warning(
217    tmp_path: Path, make_app: _MakeApp
218) -> None:
219    """A ``git_root`` below a directory that cannot be read is treated as unset, as a
220    missing one is, instead of failing the build at ``config-inited``."""
221    project = tmp_path / "proj"
222    project.mkdir()
223    locked = tmp_path / "locked"
224    (locked / "inner").mkdir(parents=True)
225    _project(
226        project,
227        toml_replace=LOCAL_ONLY,
228        toml_extra=_git_root((locked / "inner").as_posix()),
229    )
230    locked.chmod(0)
231    try:
232        app = _build(project, make_app)
233    finally:
234        locked.chmod(0o755)
235
236    assert [w for w in build_warnings(app) if IGNORED in w] == [
237        _git_root_warning(locked.resolve() / "inner", "cannot be read")
238    ]
239    assert {ref.path for ref in _records(app)} == {"src/refs.cpp"}
240
241
242def test_a_git_root_above_src_dir_changes_nothing(
243    tmp_path: Path, make_app: _MakeApp
244) -> None:
245    """``git_root = ".."`` -- the repository, an ancestor of ``src_dir`` -- warns
246    nothing new, and the records and URLs are those of a build without it."""
247    commit = _project(tmp_path)
248    detected = _build(tmp_path, make_app)
249    toml = tmp_path / "docs" / "ubproject.toml"
250    toml.write_text(
251        toml.read_text(encoding="utf-8") + _git_root(".."), encoding="utf-8"
252    )
253    configured = _build(tmp_path, make_app)
254
255    # (a second application in one test re-registers Sphinx's nodes, and says so)
256    assert [w for w in build_warnings(configured) if "already registered" not in w] == [
257        DANGLING
258    ]
259    assert _refs(configured) == _refs(detected)
260    assert _refs(configured)["REQ_001"] == [_url(commit, 1), _url(commit, 3)]
261    assert _records(configured) == _records(detected)
262    assert project_roots(
263        configured.confdir, CodeLinksConfig.from_sphinx(configured.config)
264    ) == {"src": tmp_path.resolve().as_posix()}
265
266
267# -- a file that resolves to outside src_dir -----------------------------------------
268
269
270@pytest.mark.parametrize(
271    "urls",
272    [
273        pytest.param(LOCAL_ONLY, id="local"),
274        pytest.param(REMOTE_ONLY, id="remote"),
275        pytest.param(None, id="both"),
276    ],
277)
278def test_a_file_link_out_of_src_dir_is_not_traced(
279    tmp_path: Path, make_app: _MakeApp, urls: tuple[str, str] | None
280) -> None:
281    """``src/ext_link.cpp`` -> ``outside/ext.cpp``: the build succeeds, the file is
282    neither traced nor referenced, and one warning names the link."""
283    _project(tmp_path, toml_replace=urls, files={"outside/ext.cpp": OUTSIDE_SOURCE})
284    _link_out(tmp_path, "src/ext_link.cpp", "outside/ext.cpp")
285    app = _build(tmp_path, make_app)
286
287    root = tmp_path.resolve()
288    assert build_warnings(app) == [
289        _outside_warning(
290            root / "src" / "ext_link.cpp", root / "outside" / "ext.cpp", root / "src"
291        ),
292        DANGLING,
293    ]
294    assert "IMPL_OUTSIDE" not in _json(app)["needs"]
295    assert {ref.path for ref in _records(app)} == {"src/refs.cpp"}
296
297
298@pytest.mark.parametrize(
299    ("show", "expected"),
300    [
301        # Sphinx renders the suffix itself, so the package logger adds none
302        pytest.param(True, ["[codelinks.outside_src_dir]"], id="on"),
303        # before Sphinx 8 the package logger appends it; from 8 nothing renders it
304        pytest.param(
305            False,
306            [] if _SHOWS_WARNING_TYPES else ["[codelinks.outside_src_dir]"],
307            id="off",
308        ),
309    ],
310)
311def test_the_type_suffix_is_rendered_at_most_once(
312    tmp_path: Path, make_app: _MakeApp, show: bool, expected: list[str]
313) -> None:
314    """#2091: on Sphinx 7.4 with the option on, it was rendered twice."""
315    conf = {"docs/conf.py": f"\nshow_warning_types = {show}\n"}
316    _project(tmp_path, files={"outside/ext.cpp": OUTSIDE_SOURCE}, append=conf)
317    _link_out(tmp_path, "src/ext_link.cpp", "outside/ext.cpp")
318    outside = [w for w in build_warnings(_build(tmp_path, make_app)) if "outside" in w]
319    assert [re.findall(r"\[codelinks\.[a-z_]+\]", w) for w in outside] == [expected]
320
321
322def test_a_link_out_is_warned_once_when_its_scope_is_walked_again(
323    tmp_path: Path, make_app: _MakeApp
324) -> None:
325    """A file added to the scope re-reads ``index``: the scope is walked twice in that
326    build (the fingerprint, then the directive), and the link is warned once."""
327    _project(tmp_path, files={"outside/ext.cpp": OUTSIDE_SOURCE})
328    _link_out(tmp_path, "src/ext_link.cpp", "outside/ext.cpp")
329    _build(tmp_path, make_app)
330    (tmp_path / "src" / "added.cpp").write_text(
331        "// @need-ids: REQ_001\n", encoding="utf-8"
332    )
333    app = _build(tmp_path, make_app, freshenv=False)
334
335    outside = [w for w in build_warnings(app) if "codelinks.outside_src_dir" in w]
336    assert len(outside) == 1, build_warnings(app)
337    assert "IMPL_OUTSIDE" not in _json(app)["needs"]
338
339
340def test_a_followed_directory_link_out_of_src_dir_traces_none_of_its_files(
341    tmp_path: Path, make_app: _MakeApp
342) -> None:
343    """``follow_links = true`` and ``src/dirlink`` -> ``outside/odir``: each file
344    below the link is warned once, by its path through the link, and none is traced."""
345    _project(
346        tmp_path,
347        toml_replace=(
348            'comment_type = "cpp"',
349            'comment_type = "cpp"\nfollow_links = true',
350        ),
351        files={
352            "outside/odir/one.cpp": OUTSIDE_SOURCE,
353            "outside/odir/two.cpp": "// @other outside need, IMPL_OUTSIDE_2\n",
354        },
355    )
356    _link_out(tmp_path, "src/dirlink", "outside/odir", directory=True)
357    app = _build(tmp_path, make_app)
358
359    root = tmp_path.resolve()
360    outside = sorted(w for w in build_warnings(app) if "outside_src_dir" in w)
361    assert outside == [
362        _outside_warning(
363            root / "src" / "dirlink" / name,
364            root / "outside" / "odir" / name,
365            root / "src",
366        )
367        for name in ("one.cpp", "two.cpp")
368    ]
369    needs = _json(app)["needs"]
370    assert "IMPL_OUTSIDE" not in needs
371    assert "IMPL_OUTSIDE_2" not in needs
372    assert {ref.path for ref in _records(app)} == {"src/refs.cpp"}
373
374
375def test_a_file_scope_naming_a_link_out_of_src_dir_traces_nothing(
376    tmp_path: Path, make_app: _MakeApp
377) -> None:
378    """``:file: ext_link.cpp`` names an existing file outside ``src_dir``: the scope
379    is empty, the link is warned, and the build goes on."""
380    file_scope = ".. src-trace::\n   :project: src\n   :file: ext_link.cpp\n"
381    _project(
382        tmp_path,
383        files={
384            "outside/ext.cpp": OUTSIDE_SOURCE,
385            "docs/index.rst": INDEX.replace(DIRECTIVE, file_scope),
386        },
387    )
388    _link_out(tmp_path, "src/ext_link.cpp", "outside/ext.cpp")
389    app = _build(tmp_path, make_app)
390
391    root = tmp_path.resolve()
392    assert build_warnings(app) == [
393        _outside_warning(
394            root / "src" / "ext_link.cpp", root / "outside" / "ext.cpp", root / "src"
395        )
396    ]
397    assert "IMPL_OUTSIDE" not in _json(app)["needs"]
398    assert _records(app) == []
399
400
401def test_a_config_only_project_skips_a_link_out_and_attaches_the_rest(
402    tmp_path: Path, make_app: _MakeApp
403) -> None:
404    """No directive traces ``src``: the configuration pass walks it through the same
405    discovery, warns about the link, and attaches every other reference."""
406    commit = _project(
407        tmp_path,
408        files={
409            "outside/ext.cpp": OUTSIDE_SOURCE,
410            "docs/index.rst": INDEX.replace(DIRECTIVE, ""),
411        },
412    )
413    _link_out(tmp_path, "src/ext_link.cpp", "outside/ext.cpp")
414    app = _build(tmp_path, make_app)
415
416    root = tmp_path.resolve()
417    assert build_warnings(app) == [
418        _outside_warning(
419            root / "src" / "ext_link.cpp", root / "outside" / "ext.cpp", root / "src"
420        ),
421        DANGLING,
422    ]
423    refs = _refs(app)
424    assert refs["REQ_001"] == [_url(commit, 1), _url(commit, 3)]
425    assert refs["REQ_002"] == [_url(commit, 3)]
426
427
428def test_the_cli_discover_lists_no_file_outside_src_dir(tmp_path: Path) -> None:
429    """``codelinks discover`` lists the files inside and warns on stderr."""
430    (tmp_path / "src").mkdir()
431    (tmp_path / "outside").mkdir()
432    (tmp_path / "src" / "a.cpp").write_text("// a\n", encoding="utf-8")
433    (tmp_path / "outside" / "ext.cpp").write_text("// ext\n", encoding="utf-8")
434    _link_out(tmp_path, "src/ext_link.cpp", "outside/ext.cpp")
435
436    result = CliRunner().invoke(
437        cli, ["discover", str(tmp_path / "src"), "--no-gitignore"]
438    )
439
440    assert result.exit_code == 0, result.output
441    root = tmp_path.resolve()
442    assert result.stdout.splitlines() == [
443        "1 files discovered",
444        str(root / "src" / "a.cpp"),
445    ]
446    stderr = re.sub(r"\x1b\[[0-9;?]*[A-Za-z]|\s+", "", result.stderr)
447    assert (
448        f"{(root / 'src' / 'ext_link.cpp').as_posix()}resolvesto"
449        f"{(root / 'outside' / 'ext.cpp').as_posix()},outsidesrc_dir"
450        f"{(root / 'src').as_posix()}:nottraced"
451    ) in stderr
452
453
454def test_a_link_leaving_a_directory_scope_but_not_src_dir_is_traced(
455    tmp_path: Path,
456) -> None:
457    """The rule names the PROJECT's ``src_dir``, not the scope's directory: a link in
458    ``:directory: a`` to ``b/x.cpp`` below the same ``src_dir`` is found, as it was."""
459    src = tmp_path / "src"
460    (src / "a").mkdir(parents=True)
461    (src / "b").mkdir()
462    (src / "b" / "x.cpp").write_text("// x\n", encoding="utf-8")
463    _link_out(tmp_path, "src/a/x_link.cpp", "src/b/x.cpp")
464    base = SourceDiscoverConfig(src_dir=src, gitignore=False)
465
466    found = discover_scope(src.resolve(), base, "directory", "a", exclude=())
467
468    assert found == [(src / "b" / "x.cpp").resolve()]
469
470
471@pytest.mark.parametrize(
472    ("flavour", "path", "directory", "expected"),
473    [
474        (posixpath, "/src2/x.cpp", "/src", False),
475        (posixpath, "/src/x.cpp", "/src", True),
476        (posixpath, "/src/sub/x.cpp", "/src", True),
477        (posixpath, "/src", "/src", False),
478        (posixpath, "/x.cpp", "/", True),
479        (posixpath, "/SRC/x.cpp", "/src", False),
480        (ntpath, "C:\\src2\\x.cpp", "C:\\src", False),
481        (ntpath, "c:\\SRC\\x.cpp", "C:\\src", True),
482        (ntpath, "C:\\x.cpp", "C:\\", True),
483        (ntpath, "\\\\server\\share\\src\\x.cpp", "\\\\server\\share\\src", True),
484        (ntpath, "C:/src/x.cpp", "C:\\src", True),
485        (ntpath, "D:\\src\\x.cpp", "C:\\src", False),
486    ],
487)
488def test_lies_within(
489    monkeypatch: pytest.MonkeyPatch,
490    flavour: Any,
491    path: str,
492    directory: str,
493    expected: bool,
494) -> None:
495    """Below the directory, never beside it (``src2`` is not below ``src``), with the
496    platform's case folding; a path equal to the directory is no file below it."""
497    monkeypatch.setattr(source_discover, "os", SimpleNamespace(path=flavour))
498    pure = PureWindowsPath if flavour is ntpath else PurePosixPath
499
500    assert source_discover.lies_within(pure(path), pure(directory)) is expected
501
502
503def _repository(root: Path, remote: str) -> str:
504    """Make ``root`` a repository with ``remote`` as ``origin``; return its commit."""
505    git = ["git", "-c", "user.name=t", "-c", "user.email=t@t"]
506    git += ["-c", "commit.gpgsign=false"]
507    root.mkdir(parents=True, exist_ok=True)
508    for args in (
509        ["init", "--quiet"],
510        ["remote", "add", "origin", remote],
511        ["commit", "--quiet", "--allow-empty", "-m", "init"],
512    ):
513        subprocess.run([*git, *args], cwd=root, check=True, capture_output=True)
514    return subprocess.run(
515        ["git", "rev-parse", "HEAD"],
516        cwd=root,
517        check=True,
518        capture_output=True,
519        text=True,
520    ).stdout.strip()
521
522
523@pytest.mark.parametrize(
524    ("git_root", "problem"),
525    [
526        pytest.param("../other", "does not contain src_dir {src}", id="sibling"),
527        pytest.param("../missing", "does not exist", id="missing"),
528    ],
529)
530def test_the_cli_analyse_ignores_a_git_root_that_does_not_contain_src_dir(
531    tmp_path: Path, git_root: str, problem: str
532) -> None:
533    """``codelinks analyse`` applies the build's rule: one warning on stderr, and the
534    remote URLs formed below the repository detected from ``src_dir``."""
535    project = tmp_path / "proj"
536    (project / "src").mkdir(parents=True)
537    (project / "src" / "a.cpp").write_text(
538        "// @need-ids: REQ_1\nvoid f() {}\n", encoding="utf-8"
539    )
540    (project / "cl.toml").write_text(
541        "[codelinks.projects.p.source_discover]\n"
542        'src_dir = "src"\ncomment_type = "cpp"\ngitignore = false\n'
543        "[codelinks.projects.p.analyse]\n"
544        f'git_root = "{git_root}"\n',
545        encoding="utf-8",
546    )
547    commit = _repository(project, "https://github.com/example/demo.git")
548    _repository(tmp_path / "other", "https://github.com/example/other.git")
549    out = tmp_path / "out"
550    out.mkdir()
551
552    result = CliRunner().invoke(
553        cli, ["analyse", str(project / "cl.toml"), "--outdir", str(out)]
554    )
555
556    assert result.exit_code == 0, result.output
557    root = tmp_path.resolve()
558    src = (root / "proj" / "src").as_posix()
559    named = (root / git_root.removeprefix("../")).as_posix()
560    expected = (
561        f"project 'p': git_root {named} {problem.format(src=src)}; it is ignored, "
562        "and the repository root is detected from src_dir instead"
563    )
564    stderr = re.sub(r"\x1b\[[0-9;?]*[A-Za-z]|\s+", "", result.stderr)
565    assert re.sub(r"\s+", "", expected) in stderr
566    markers = json.loads((out / "marked_content.json").read_text(encoding="utf-8"))
567    assert [marker["remote_url"] for marker in markers["p"]] == [
568        f"https://github.com/example/demo/blob/{commit}/src/a.cpp#L1"
569    ]
570
571
572def test_git_root_problem(tmp_path: Path) -> None:
573    """The one check both the build and ``codelinks analyse`` apply, row by row."""
574    src = tmp_path / "repo" / "src"
575    src.mkdir(parents=True)
576    (tmp_path / "file").write_text("", encoding="utf-8")
577    (tmp_path / "other").mkdir()
578    root = tmp_path.resolve()
579    src = src.resolve()
580
581    assert git_root_problem(root / "repo", src) is None
582    assert git_root_problem(src, src) is None
583    assert git_root_problem(root, src) is None
584    assert git_root_problem(root / "missing", src) == (
585        f"git_root {(root / 'missing').as_posix()} does not exist"
586    )
587    assert git_root_problem(root / "file", src) == (
588        f"git_root {(root / 'file').as_posix()} is not a directory"
589    )
590    assert git_root_problem(root / "other", src) == (
591        f"git_root {(root / 'other').as_posix()} does not contain src_dir "
592        f"{src.as_posix()}"
593    )