# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
"""Checks of the tool configurations `repomatic init` writes into `pyproject.toml`.
A section `init` seeds once and never revisits misses the knobs its template
gains later. A section synced on every run can keep a bare entry beside the
anchored form that replaced it.
"""
from __future__ import annotations
import json
import re
from collections.abc import Mapping
import tomlrt
from ..bundle import get_data_content
from ..checks import CheckResult
from ..components import SyncMode
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Iterable, Iterator
from typing import Any
from .context import LintContext
[docs]
def missing_template_entries(
template: Mapping[str, Any],
local: Mapping[str, Any],
prefix: str = "",
) -> Iterator[tuple[str, str | None]]:
"""Yield the dotted paths a seeded tool section does not carry.
Absence is the only thing reported. A scalar the repository tuned is its
own answer, a key it added is none of the template's business, and a
comment it rewrote is invisible here: all three are what
{attr}`~repomatic.components.SyncMode.BOOTSTRAP` hands over on the first
write. What that contract cannot express is a knob the template gained
*afterwards*, which reaches nobody because `init` never revisits the
section.
A list is compared by membership, not by equality. An ignore list or a
select list grows one entry at a time and its order carries no meaning, so
equality would report every local addition as drift.
:param template: The bundled template, parsed.
:param local: The repository's own section, parsed.
:param prefix: Dotted path of the table being walked, for recursion.
:return: `(path, None)` per missing key, `(path, member)` per missing list
member, named by {func}`template_member_name`.
"""
for key, expected in template.items():
path = f"{prefix}{key}"
if key not in local:
yield path, None
continue
found = local[key]
if isinstance(expected, Mapping) and isinstance(found, Mapping):
yield from missing_template_entries(expected, found, f"{path}.")
elif isinstance(expected, list) and isinstance(found, list):
for item in expected:
if item not in found:
yield path, template_member_name(item)
[docs]
def template_member_name(member: object) -> str:
"""Name one member of a template list the way a `lint-repo` line reads it.
A scalar is its own name. A table has no text of its own, so its name is
the inline table that writes it, like `{ fruit = "apple", ripe = true }`.
That is the text a maintainer copies into the section, or quotes as the key
of a {attr}`~repomatic.config.LintRepoConfig.declined` entry.
:param member: One item of a list held by a bundled template.
:return: The member as text, or as inline TOML when it is a table.
"""
if not isinstance(member, Mapping):
return str(member)
plain = member.to_dict() if isinstance(member, tomlrt.Table) else dict(member)
# tomlrt renders a value only as part of a document, so the table is
# written under a throwaway key that is then cut off.
rendered = tomlrt.dumps({"member": tomlrt.Table.inline(plain)})
return rendered.removeprefix("member = ").rstrip("\n")
_BARE_TOML_KEY = re.compile(r"[A-Za-z0-9_-]+")
"""A TOML key that needs no quotes."""
def _dotted_toml_key(path: Iterable[str]) -> str:
"""Render a key path as the dotted TOML key that names it."""
return ".".join(
part if _BARE_TOML_KEY.fullmatch(part) else json.dumps(part, ensure_ascii=False)
for part in path
)
def _template_entry(path: str, member: str | None) -> str:
"""Name one bundled template entry the way a `lint-repo` line reads it."""
return f"`{path}`" if member is None else f'`{path}` member "{member}"'
[docs]
def check_bootstrap_config_drift(ctx: LintContext) -> Iterator[CheckResult]:
"""Report bundled knobs a seeded tool section never received.
`init` writes the template of a
{attr}`~repomatic.components.SyncMode.BOOTSTRAP` component once and hands
the section over, so the repository owns every value from then on. That is
deliberate, and this check does not argue with it. The gap it closes is
narrower: a template that gains a knob *after* the seeding has no route into
the repository and nothing that mentions the difference, so the section
silently stops being the configuration repomatic ships.
Advisory, and deliberately so. A repository may have dropped a template
rule on purpose, and only its maintainer can tell that from a section left
behind.
The maintainer records that answer in
{attr}`~repomatic.config.LintRepoConfig.declined`. A declined entry is
reported as skipped, with its reason: the decision stays in the report and
the warning stops. The record is checked like the section it speaks for.
An entry that declines nothing is reported, since the next gap at its path
would otherwise arrive already declined.
A section keeping its keys in the component's
{attr}`~repomatic.components.ToolConfigComponent.legacy_table` is measured
there, and the finding names that sub-table. An entry is declined at its
path below the tool either way, without the sub-table.
:return: One result per declined entry and one per adopted BOOTSTRAP
section, then one per recorded entry that declines nothing.
"""
tool_table = ctx.tool_table
declined = ctx.config.lint_repo.declined_entries()
seeded = {comp.tool_name: comp for comp in SyncMode.BOOTSTRAP.components()}
# Tools whose section was compared with its template, and tools whose
# template could not be read.
measured: set[str] = set()
unreadable: set[str] = set()
# Entries that took a finding out of the report, and entries that only
# lack a reason to do so.
applied: set[tuple[str, ...]] = set()
unreasoned: set[tuple[str, ...]] = set()
for comp in seeded.values():
local = tool_table.get(comp.tool_name)
if not isinstance(local, Mapping):
continue
section = comp.tool_section
legacy = local.get(comp.legacy_table) if comp.legacy_table else None
if isinstance(legacy, Mapping):
local = legacy
section = f"{section}.{comp.legacy_table}"
try:
template = tomlrt.loads(get_data_content(comp.source_file))
except (OSError, ValueError, tomlrt.TOMLParseError):
unreadable.add(comp.tool_name)
yield CheckResult(
None,
f"[{section}] drift: skipped (bundled"
f" `{comp.source_file}` could not be read).",
)
continue
measured.add(comp.tool_name)
missing: list[tuple[str, str | None]] = []
declines = False
for path, member in missing_template_entries(template, local):
if (path, member) in comp.customizable_entries:
continue
entry: tuple[str, ...] = (comp.tool_name, *path.split("."))
if member is not None:
entry = (*entry, member)
reason = declined.get(entry)
if isinstance(reason, str) and reason.strip():
applied.add(entry)
declines = True
yield CheckResult(
None,
f"[{section}] declines {_template_entry(path, member)}:"
f" {reason.strip()}",
)
continue
if entry in declined:
unreasoned.add(entry)
missing.append((path, member))
if missing:
listed = ", ".join(_template_entry(*found) for found in missing)
yield CheckResult(
False,
f"[{section}] does not carry"
f" {listed} from the"
f" bundled `{comp.source_file}`. `init` seeds this section"
f" once and never revisits it, so a knob the template gained"
f" since arrives only by hand. Copy what applies, and name what"
f" stays out under `[tool.repomatic] lint-repo.declined` with"
f" its reason.",
)
continue
scope = "every other entry" if declines else "every entry"
yield CheckResult(
True,
f"[{section}] carries {scope} of the bundled `{comp.source_file}`.",
)
for entry in declined:
tool = entry[0]
if entry in applied or tool in unreadable:
continue
named = (
f"`[tool.repomatic] lint-repo.declined` entry `{_dotted_toml_key(entry)}`"
)
owner = seeded.get(tool)
if entry in unreasoned:
verdict = (
"gives no reason, so the entry it names stays reported. Map it"
" to the reason that entry stays out."
)
elif owner is None:
verdict = (
f"declines nothing: `{tool}` is not a tool config `init` seeds"
f" once. Remove the entry."
)
elif tool not in measured:
verdict = (
f"declines nothing: the repository has no"
f" `[{owner.tool_section}]` section. Remove the entry."
)
else:
verdict = (
f"declines nothing: `[{owner.tool_section}]` carries what it"
f" names, or the bundled `{owner.source_file}` does not. Remove"
f" the entry."
)
yield CheckResult(False, f"{named} {verdict}")
_ANCHORED_EXCLUDE_HEAD = re.compile(r"^\^https://\(www\\\.\)\?")
"""The `^https://(www\\.)?` head of an anchored `[tool.lychee] exclude` entry."""
_ANCHORED_EXCLUDE_TAIL = re.compile(r"\(/\.\*\)\?\$$")
"""The `(/.*)?$` tail of an anchored `[tool.lychee] exclude` entry."""
_EXCLUDE_PATTERN_TOOLS = frozenset({"lychee"})
"""Synced tool configs whose array holds regex patterns rather than plain values.
{func}`check_superseded_local_excludes` reads a bare entry as the unanchored
form of an anchored one, which means something only for a list of patterns
matched against URLs. Every other synced array holds values with no
anchored/bare distinction, so the check stays out of them.
"""
[docs]
def anchored_exclude_core(pattern: str) -> str | None:
"""Return the bare host pattern an anchored exclude entry supersedes.
`^https://(www\\.)?x\\.com(/.*)?$` answers `x\\.com`. Anything else answers
`None`: an entry not anchored on both ends replaced no bare form, and one
carrying a path between the host and the closing anchor
(`^https://www\\.npmjs\\.com/package/.*$`) has no bare form that was ever
bundled on its own.
:param pattern: One `[tool.lychee] exclude` entry.
:return: The unanchored host pattern, or `None` when there is none.
"""
if not _ANCHORED_EXCLUDE_HEAD.match(pattern):
return None
core = _ANCHORED_EXCLUDE_HEAD.sub("", pattern)
if not _ANCHORED_EXCLUDE_TAIL.search(core):
return None
return _ANCHORED_EXCLUDE_TAIL.sub("", core)
[docs]
def check_superseded_local_excludes(ctx: LintContext) -> Iterator[CheckResult]:
"""Report a local exclude entry the bundled template has since anchored.
An ongoing sync rebuilds a section from its template and grafts local-only
content back on, and for an array the graft is a union by value: an item
the template does not carry is appended after the ones it does. That is
what keeps a repository's own entries alive across a sync, and it has no
inverse. When the template *replaces* an entry instead of adding one, the
old form becomes local-only by the next sync, so it is grafted back beside
its own replacement and nothing ever retires it.
Anchoring a bare host pattern is exactly that replacement, and the stale
copy is not inert: lychee reads these entries as substring matches, so a
bare `x\\.com` kept beside `^https://(www\\.)?x\\.com(/.*)?$` goes on
excluding every domain ending in `x.com`, `cloudlinux.com` among them,
which is what the anchored form was written to stop doing.
The fix depends on what sits beside the stale entry. Where the anchored
form is present, dropping the bare one is the whole fix. A section no sync
reaches holds the bare form alone: lychee is an awesome-only component, so
another repository keeps a copy nothing rebuilds unless
`[tool.repomatic] include` opts it in. Dropping the entry there would stop
excluding the host itself, so the result names the anchored form to write
in its place.
Advisory. Only the maintainer can separate a bare entry the template
superseded from one the repository narrowed on purpose.
```{note}
The test is deliberately narrow: it recognises the anchored/bare pair by
string equality against the head and tail above, so it catches a template
that anchored a host pattern and nothing else. A local entry broadened in
some other way is not reported. Widening it means inverting a regex to
compare match sets, which is not worth a dependency for one signature that
already reached every repository the bundled `lychee.toml` deploys to.
```
:return: One result per synced tool config holding exclude patterns.
"""
for comp in SyncMode.ONGOING.components():
if comp.tool_name not in _EXCLUDE_PATTERN_TOOLS:
continue
section = ctx.tool_table.get(comp.tool_name)
if not isinstance(section, Mapping):
continue
local = section.get("exclude")
if not isinstance(local, list):
continue
try:
template = tomlrt.loads(get_data_content(comp.source_file))
except (OSError, ValueError, tomlrt.TOMLParseError):
yield CheckResult(
None,
f"[{comp.tool_section}] exclude drift: skipped (bundled"
f" `{comp.source_file}` could not be read).",
)
continue
bundled = template.get("exclude")
if not isinstance(bundled, list):
yield CheckResult(
None,
f"[{comp.tool_section}] exclude drift: skipped (bundled"
f" `{comp.source_file}` declares no `exclude` array).",
)
continue
anchors = {
core: str(item)
for item, core in (
(item, anchored_exclude_core(str(item))) for item in bundled
)
if core is not None
}
stale = tuple(
item
for item in local
if isinstance(item, str)
and item not in bundled
and not item.startswith("^")
and item in anchors
)
if stale:
listed = ", ".join(f"`{item}`" for item in stale)
fixes = []
for item in stale:
anchored = anchors[item]
if anchored in local:
fixes.append(
f"Drop `{item}`: `{anchored}` beside it already"
f" excludes the host, and an ongoing sync never"
f" retires the bare form."
)
else:
fixes.append(
f"Replace `{item}` with `{anchored}`: the section"
f" lacks the anchored form, so dropping the bare one"
f" would stop excluding the host itself."
)
yield CheckResult(
False,
f"[{comp.tool_section}] exclude carries {listed}, which the"
f" bundled `{comp.source_file}` now anchors: lychee reads"
f" these as substring matches, so a bare form keeps excluding"
f" every domain ending in the same suffix. {' '.join(fixes)}",
)
continue
yield CheckResult(
True,
f"[{comp.tool_section}] exclude carries no entry the bundled"
f" `{comp.source_file}` superseded.",
)