- Declare `__all__` in every package `__init__.py` whose `PROJECT_ROOT`
is imported by its modules (163 packages), so the constant counts as
exported; list every public name where the package defines more.
- Empty the 15 package `__init__.py` files whose `PROJECT_ROOT` no module
imports.
- Declare the public API of `utils.github.variant.axes` in `__all__`,
re-exports included, instead of silencing them with `noqa: F401`; the
list matches the one `feature/store-i18n` already carries.
- Use `import unittest.mock as mock` in `test_applications.py`, as the
rest of the suite does.
- Spell `_NON_PRINTABLE` in `utils/yaml_bootstrap.py` as the equivalent
BMP-only positive class; it matches exactly the same 2111 code points
as the old negated class across U+0000..U+10FFFF.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
_all_project_files carried @lru_cache(maxsize=1) and read PROJECT_ROOT from the
module global inside its body, so the root was not part of the cache key. The
first call decided the answer for the rest of the process: point PROJECT_ROOT at
a fixture afterwards and the walk still reports the real repository, or, in the
other order, a sweep after the fixture is torn down enumerates a directory that
no longer exists.
Both failure modes are silent. iter_project_files, iter_project_files_with_content
and iter_non_ignored_files all sit on this one walk, 202 files in the tree use
them, and a lint whose corpus collapses to zero paths reports zero offenders and
passes. Nothing raises.
The root is now a parameter and therefore the key, with room for four of them.
_reset() still clears it through cache_clear(), and the only other caller is
iter_project_files, which passes str(PROJECT_ROOT) explicitly.
Live impact today is none, and that is worth stating: exactly one test patches
PROJECT_ROOT in this module, and it calls _reset() in both setUp and
addCleanup, so the defect was fully masked. Every other patch site in the repo
targets PROJECT_ROOT in a different module. This is about the next test that
forgets, not about a failure anyone has seen.
A regression test covers it rather than a comment. Negative control: with the
key removed the new test fails on the second root with
Items in the first set but not the second: '/tmp/.../first.txt'
and passes once the key is back; the file is byte-identical to its pre-probe
state afterwards.
The lint docstring that quoted the old cache shape is deleted rather than
updated, because a cross-module implementation reference goes stale the moment
that module changes, which is exactly what happened here.
Same class as c4556fe72d, which keyed the applications SPOT by the groups its
payload reads. That one was a live defect in utils/cache/base.py; this is the
latent twin one module over.
Residual: maxsize=4 is a choice, not a measurement. More than four roots alive
at once evicts the oldest, which re-walks rather than answers wrongly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
API is a plain mapping, so an inventory that configures three providers replaces the whole declaration from group_vars/all/18_api.yml instead of merging into it. inventories/development/default.yml declares openai, anthropic and openrouter, so github, google and jira did not exist in CI at all and lookup('api', 'github.client_id') raised.
A raising lookup inside a value the applications renderer is templating does not fail the render: _templar_render_best_effort returns the string untouched and, for a value carrying a non-env lookup(, refuses the fallback as well. The raw template text reached plugin_value.enabled | bool, which coerced it to False and emitted the deprecation warning that fails the deploy gate. Run 34893363518, job 104153194109 lost web-app-nextcloud to exactly that, for three integrations at once. The configvalue entries would have stored template text as the client id.
utils/api.py merges the scope's override over the declaration, so an override narrows values and never the provider set, and holds the enable rule: every credential a provider declares must carry a value. The api lookup reads through it; the new api_enabled lookup gates on it and returns a real boolean instead of a string a filter has to coerce.
The same class sat in group_vars/all/16_ai.yml and roles/svc-ai-litellm/meta/secrets.yml as bare API.<provider>.<key> dereferences; both now go through the lookups.
Two lints keep it shut. test_addon_enabled_renders_to_bool renders the whole applications map against a scope with API blanked, the most hostile override an inventory may legally write, and requires every addon gate to come out a boolean. test_api_accessed_via_lookup forbids a bare API dereference in Jinja under roles/, group_vars/ and inventories/.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
isort moved unittest ahead of pathlib and dropped the blank line between them; ruff wrapped the assertTrue message, the re.compile concatenation and the join generator at the 88 column boundary. No regex, condition or assertion changes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
plugins/lookup/tls.py:102 builds url.base as "{protocol}://{domain}/", pinned by test_tls.py:59. meta/addons/onlyoffice.yml appended another one, so DocumentServerInternalUrl was stored as https://host//.
The connector treats its two URLs differently: getDocumentServerUrl normalises with rtrim, getDocumentServerInternalUrl returns the stored string raw and concatenates the endpoint onto it. Every server-side call therefore went to //healthcheck and //converter. A swarm vhost forwards $request_uri verbatim, so the document server answered 404; a compose vhost uses proxy_pass with a URI part and nginx collapses the double slash. The 404 makes the connector cron write settings_error, and FilesListener then stops adding the onlyoffice script to the Files view, which is why iframe#onlyofficeFrame never appears and the addon spec times out. Observed in run 33828309745 job 100890535389: "GET //healthcheck HTTP/2.0" 404 in the openresty log, and the docx row offering Download as its default action.
The lint generalises it. This class has been fixed one site at a time at least five times before (efee415b21, 035c1efbf3, b0c7c9601e, a96bdbba38, 83636408c9), so the rule now holds repo-wide: a variable carrying an unstripped tls url.base may never be followed by a slash. Definitions that strip it again are excluded, which is what keeps KEYCLOAK_URL and OPENTALK_KEYCLOAK_BASE_URL out of the finding set; measured, no name is defined both ways.
Not fixed here: settings_error is sticky, so an instance already poisoned stays broken after a redeploy. The obvious remedy, gating the deploy on occ onlyoffice:documentserver --check, is a four-step round trip whose steps never succeeded in either artifact, so it needs its own design.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
3529 invalid comments in 528 files, grandfathered in while the linter
only saw the working-tree diff. Removal ran through the linter's own
collectors, so detection and deletion cannot drift: a comment whose
opener line is blank before it and whose closer line is blank after it
loses all its lines, anything else loses only the comment region, and a
line left holding a bare delimiter goes with it.
Five files keep their comments behind a nocheck marker instead. Four are
upstream configs whose inline docs are upstream's own (mautrix, systemd
journald, nextcloud-oidc-login, MediaWiki). In littlejs the
commented-out entries ARE the deactivation mechanism for an example
tile, so deleting them would delete configuration.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The collector walked every line of a vars: block and treated each key it met
as a variable passed to the include, whatever its depth. In
vars:
role_templates:
- src_glob: "config/*.j2"
dest_dir: "{{ NEXTCLOUD_HOST_CONF_ADD_PATH }}"
mode: "0644"
exactly one variable is passed. src_glob, dest_dir and mode are fields of its
value, and demanding a Jinja appearance for each of them is a question the data
cannot answer.
It went unnoticed while every such field happened to occur in some template
somewhere. Moving the role-templates expansion into a lookup ended that:
dest_dir is now read in Python, the lint stopped seeing it, and a correct
refactor came out as a lint failure naming a variable that is very much used.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The deploy CLI shipped every mode as -e KEY=value, which ansible-playbook parses as a string at the highest precedence. MODE_DEBUG=false therefore arrived as the non-empty, and so truthy, string false. Every when: MODE_X | bool was unaffected because the filter maps it back, but four sites read a flag bare and silently inverted: openldap wrote LDAP attributes with state exact on every deploy instead of only under MODE_CLEANUP, SYS_SERVICE_DEFAULT_STATE was restarted where it should have been omit, SYS_SERVICE_RUNNER_STATE was never started, and mailu always logged at DEBUG.
Send the modes as one JSON extra-var, which keeps the declared type, and give those four sites the | bool they were missing so the meaning of a flag no longer depends on how its value reached Ansible. A lint pins the rule: a MODE_* read in a truthiness position carries the filter or a nocheck naming a genuine string comparison.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three folded 'when:/failed_when: >- {{ ... }}' conditionals (mediawiki, joomla, sys-front-tls-selfsigned) triggered the ansible-core 'Conditionals should not be surrounded by templating delimiters' deprecation, which fails the deploy deprecation gate. Drop the delimiters (a folded conditional is already a bare Jinja expression).
Extend two lints so this cannot regress and does not misfire: test_no_templated_conditionals now catches folded/block scalars (was inline-only); test_vars_usage_in_yaml now scans failed_when/until/changed_when for var usage (was when/loop/with_* only), so bare-expression conditionals no longer make their vars look unused.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
DEPLOYMENT_MODE is binary (swarm / compose), so a negated `!= 'swarm'` gate
always means `== 'compose'`. Replace every negated gate across roles/ and
group_vars with the positive form for readability, and add a lint
(tests/lint/ansible/variables/test_no_deployment_mode_negation.py) that forbids
`DEPLOYMENT_MODE !=` in .yml/.yaml/.j2 with a `# nocheck:
deployment-mode-negation` escape.
The compose-verb / chdir / jinja-gate lint guidance messages now suggest the
positive form too; the gate matcher (utils/annotations/task_gate.py) and its
test inputs keep recognising the legacy `!= 'swarm'` so a nocheck-marked
exception is still detected as compose-only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- web-opt-rdr-www canonical: rdr-www.{DOMAIN_PRIMARY} -> w3redirect.{DOMAIN_PRIMARY}.
- New lookup `www_redirect_domains` (role-scoped): returns full {source, target}
mapping list, resolves DOMAIN_HOMEPAGE target for the role's own canonical via
lookup('domain', 'web-opt-rdr-www'). REDIRECT_WWW_DOMAINS now carries dicts;
tasks/main.yml and _01_cloudflare_edge_redirect.yml updated to consume dicts.
- New lookup `current_play_redirect_domains`: replaces the two inline
set_fact merges in tasks/stages/01_constructor.yml. Merges user-supplied
redirect_domain_mappings, primary-redirect (conditional on web-opt-rdr-domains
in deployed), and per-app domain mappings (inlined from the now-removed
domain_mappings filter).
- domain_redirect_mappings filter removed; web-app-keycloak RedirectUrisTest
pollution of utils.roles.applications.config in sys.modules fixed via
tearDownClass cleanup.
- csp_skip_domains lookup: csp.health.skip opt-out path removed (architecture
no longer needs it).
- Lint test_only_web_app_roles_integrate_dashboard expanded from web-app-* to
web-*; test_role_and_group_vars_used now scans .py files too so vars consumed
only inside plugins (DOMAIN_HOMEPAGE, AUTO_BUILD_ALIASES) are seen.
- New role-scoped variants.yml on web-opt-rdr-www with dashboard on/off.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three classes of drift cleaned up across 85 .md files:
- Em-dashes: replaced ` — ` with the contextually correct separator
(colon for `term: explanation`, period to split clauses across
sentences/source lines, parentheses for asides, semicolon for
tight-linked continuations). Two intentional exceptions remain
in `docs/contributing/documentation.md` (the rule literal) and
inside a fenced ```sh block in `docs/contributing/actions/debugging/ci.md`.
- SPOT self-labels: rewrote "This is the SPOT for X" to describe
the subject directly ("This directory holds X" / "This file
describes X"). Cross-references to other artefacts' canonical
role remain unchanged.
- Heading-emoji position: moved leading-emoji headings (`# 🧩 Title`)
to trailing position (`# Title 🧩`) per the headline rule that
emojis MUST come after the title text.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restructures tests/lint/ansible/ into five concern-keyed subdirectories
(jinja/, modules/, services/, shell/, sql/) plus the pre-existing roles/
and variables/. Each new subdir ships its own __init__.py exporting
PROJECT_ROOT and a scope-focused README.md following the project's
documentation conventions (RFC 2119 keywords, emoji-after-headline,
one-sentence-per-line, no historical context).
12 existing tests move into their concern bucket via git mv so history
follows the file. Adds tests/lint/ansible/variables/test_dotted_path_keys_exist.py
which catches the bug class where a Jinja dotted path's head resolves
via group_vars/all/ or the consuming role's own vars/defaults but a
deeper segment does not exist as a key in the dict tree (e.g. the
previous `LDAP.SERVER.REACH_DOMAIN` typo in web-app-odoo).
Touches three external references so cross-links keep resolving:
- docs/contributing/actions/testing/suppression.md (run-once, shared)
- roles/sys-svc-compose/tasks/utils/network/README.md (network-via-util)
- tests/lint/ansible/sql/test_no_inline_multiline_sql.py (self-path exclude)
TC (flake8-type-checking): typing-only imports moved into
``if TYPE_CHECKING:`` blocks across 8 source files via auto-fix
(117 hits). Reduces runtime import surface and rules out circular
imports for type-only deps. Project-wide ``from __future__ import
annotations`` (FA, already enabled) keeps annotations as strings,
so the moved imports never need to load at runtime.
N (pep8-naming): 32 hits handled.
Renames (semantic + lint):
- ``isSmallerThenMaximumBackupSize`` -> ``is_larger_than_maximum_backup_size``
(snake_case + fixes both the typo "Then -> Than" and the
semantic mismatch: the function returns ``current > maximum``)
- ``isDirectoryDeletable`` / ``deleteVersion`` /
``getAmountOfIteration`` / ``deleteIteration`` -> snake_case
- 8 test functions with ALL-CAPS env-var fragments
(``test_..._DOMAIN_PRIMARY_...`` etc.) -> snake_case
- Mock alias ``VH`` -> ``vh`` in test_host_vars.py
Exception class renames (``N818`` Error suffix):
- ``TimeLimitExceededException`` -> ``TimeLimitExceededError``
- ``AttemptException`` -> ``AttemptError``
- ``PathNotFound`` -> ``PathNotFoundError`` (propagated across
4 callers under tests/integration/lookups/config/)
- ``_ExitJson`` / ``_FailJson`` -> ``*_Error``
Refactor:
- ``METADATA = {...}`` local constant in two functions of
``plugins/filter/invokable_paths.py`` lifted to module-level
``_METADATA_KEYS``.
- ``from typing import TYPE_CHECKING`` in
``tasks/utils/migrate_meta_layout/__main__.py`` moved above
the ``sys.path.insert`` hack to satisfy E402 (collateral from
the TC auto-fix that landed it after the path mutation).
Per-file ignores in pyproject.toml for the legitimate cases the
rule cannot accept:
- ``pull-specific-host.py`` -> ``N999`` (Ansible-invoked role-level
script, hyphenated filename pinned by the surrounding stack).
- 4 matrix-deploy files -> ``N806`` for the ``include_R`` /
``raw_include_R`` round-index notation; the uppercase ``R``
here mirrors the formula in the docstring rather than naming
a constant.
- ``plugins/filter/value_generator.py`` -> ``N806`` for the
lazy-loaded class binding ``ValueGenerator =
_value_generator_cls()``, which retains the imported class
identifier per PEP 8.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The project-root-import lint only flagged ``def repo_root()`` /
``def project_root()`` and the ``.parents[N]`` index expression. The
underscore-prefixed ``_repo_root`` / ``_project_root`` siblings and
the bare ``Path(__file__).resolve().parents`` chain (used in the
upward-search ``for candidate in … .parents`` loops) slipped past it.
Lint changes (tests/lint/repository/test_project_root_import.py):
- Add a forbidden pattern for the bare ``Path(__file__).…parents``
chain (no ``[N]`` index) — the iterator hand-roll used by the
``def _repo_root()`` helpers below.
- Extend the function-name regex to ``def\\s+_*(repo_root|project_root)``
so leading-underscore variants are caught alongside the public names.
- Update the docstring catalog so the failure messages stay
self-documenting.
Migrations the tightened lint required:
- tests/lint/repository/test_markdown_links.py
- tests/lint/ansible/variables/test_role_and_group_vars_used.py
- tests/lint/ansible/roles/test_playwright_env_keys_used.py
- tests/unit/roles/web-app-decidim/test_patch.py
(plus a new ``__init__.py`` for the package so PROJECT_ROOT is
importable)
- tests/unit/roles/sys-ctl-cln-bkps/files/test_script.py
(rename the env-aware helper to ``_resolved_repo_root`` — the env
override is real; only the legacy name tripped the lint)
CLI fallout from the same audit:
- cli/meta/roles/lifecycle_filter/__main__.py: drop the local
``_repo_root`` upward-walker; use ``cli.PROJECT_ROOT`` for the
default ``--roles-dir`` argument.
- cli/meta/applications/in_group_deps/__main__.py: collapse the
``_project_root`` wrapper that just returned ``str(PROJECT_ROOT)``;
the test that asserted on it now reads ``mod.PROJECT_ROOT`` directly.
Cleans up the lint debt the main->feature/web-app-kix merge surfaced.
Each change addresses a real failure surfaced by `make test`; no
``# nocheck`` blanket suppressions added.
Migrations to the shared cache helpers:
- ~25 test files swap raw ``yaml.safe_load`` / ``yaml.safe_dump`` /
``yaml.dump`` for ``utils.cache.yaml.{load_yaml_any,load_yaml_all,
load_yaml_str,dump_yaml,load_yaml_all_str}``.
- ~30 test files swap raw ``glob.glob`` / ``os.walk`` / ``Path.rglob``
for ``utils.cache.files.iter_project_files`` (or
``iter_non_ignored_files`` for the dependabot fallback walker, or
per-role ``Path.iterdir`` / ``Path.glob`` where the scope is a
single subtree).
- 3 ``Path.read_text`` lint helpers now route through
``utils.cache.files.read_text``.
Variant + service-flag fixes:
- web-app-kix: shift its subnet from 192.168.105.80/28 to
192.168.105.128/28 to clear the collision with web-app-hugo and
svc-prx-openresty.
- web-app-fediwall: add the missing ``mastodon`` / ``friendica``
service flags referenced by ``meta/variants.yml``; pin the css /
logout / dashboard / matomo / prometheus literals with
``# nocheck: dynamic-flag`` and add a third "everything off"
variant so the variant-coverage matrix sees both true and false
pinning for every dynamic flag.
- web-app-magento, web-app-shopware, web-svc-xmpp: switch the
``run_after: web-app-keycloak`` matcher from a literal-false
oauth2/oidc opt-out to the ``"{{ 'web-app-keycloak' in group_names }}"``
dynamic form, and extend each role's ``meta/variants.yml`` with the
matching true/false (and shopware's LDAP-only) coverage.
- web-app-fusiondirectory, web-app-matrix: declare the
``matomo`` flag so their ``run_after: web-app-matomo`` resolves
through an explicit services entry; add matomo true/false to both
variants files.
- web-app-fediwall, web-app-mini-qr: add the
``# nocheck: logout`` opt-out directly above ``logout:`` so the
logout-dashboard guard accepts the legitimately-disabled state.
- ~30 ``services.yml`` files: insert
``# nocheck: dynamic-flag`` directly above the literal
``css`` / ``redis`` / ``view`` / ``worker`` / ``email`` / ``html``
/ ``mariadb`` / ``opensearch`` / ``talk`` / ``logout`` blocks that
the dynamic-flag guard surfaced.
Code-quality follow-ups uncovered by ruff:
- utils/cache/yaml.py: collapse ``load_yaml_all_str`` to a one-line
return + on-call ``# nocheck: direct-yaml`` so the lint accepts
the cache module's own ``yaml.safe_load_all`` invocation.
- utils/templating.py: replace the ``Optional[Any]`` import that
slipped in from main with the modern ``Any | None``; rewrite the
cleanup ``try/except/pass`` blocks as
``contextlib.suppress(Exception)`` so the imported
``contextlib`` module is consumed consistently.
- 7 new-from-main lint test files (test_inline_script_block_size,
test_no_inline_multiline_php_in_sh, test_no_lookup_config_jinja_default,
test_no_inline_multiline_sql, test_web_role_no_web_dependency,
test_shared_primary_entity_run_once, test_fediwall_active): drop
their local ``Path(__file__).parents[N]`` PROJECT_ROOT
computations and import the package-level constant instead.
- roles/web-app-moodle/templates/playwright.env.j2: drop the
unreferenced ``KEYCLOAK_REALM_NAME`` env key that the
playwright-env-keys-used guard flagged.
Extends ``[tool.ruff.lint].select`` with six additional rule families:
``RUF`` (ruff-specific), ``SIM`` (flake8-simplify), ``C4``
(flake8-comprehensions), ``PERF`` (perflint), ``RET`` (flake8-return),
``PIE`` (flake8-pie). Adds ``RUF001/002/003`` to ``ignore`` because
those exist for homoglyph-attack detection, not English typography —
the project uses em/en-dashes and ``ℹ`` as a UI-hint glyph in CLI help
output deliberately.
Auto-fixed (~225, mechanical & behavior-preserving)
====================================================
- RUF005: ``a + [b]`` → ``[*a, b]``
- C408: ``dict(k=v)`` → ``{"k": v}``
- C420: ``dict.fromkeys(seq)`` shape simplifications
- SIM103/SIM110/SIM118: truthy-return / dict-key / dict-method
idiom simplifications
- RET504/RET505: drop unneeded final assigns / ``else`` after
``return``
- PIE810: combine ``str.startswith(a) or .startswith(b)``
into one call with a tuple
- RUF010: ``f"{str(x)}"`` → ``f"{x\!s}"``
- RUF100: removed unused ``# noqa`` directives that
became stale (mostly old ``# noqa: E402``
markers that ruff's smart E402 implementation
no longer flags after ``sys.path`` shims)
- RUF015 / RUF013 / RUF022 / RUF046 / RUF059: misc small wins
- I001: re-sorted import blocks where the new
imports above shifted them
Hand-fixed (~140, scripted where mechanical)
============================================
PERF401 (42): scripted AST rewrite of
``for x in iter: target.append(expr)`` (with optional ``if`` guard)
→ ``target.extend(expr for x in iter [if cond])``.
``list.extend(genexp)`` consumes the generator immediately, so the
rewrite is semantically equivalent to the original eager loop.
SIM117 (41): scripted AST combiner for nested ``with`` blocks where
the inner is the only statement of the outer (38 sites). The remaining
3 multi-line ``patch.object(...)`` chains were combined by hand using
the parenthesised ``with (\\n A,\\n B,\\n):`` form.
SIM102 (14): scripted AST combiner for nested ``if`` (10 sites). The
remaining 4 carry an inline ``# noqa: SIM102`` with rationale —
combining would either change semantics (the outer body has siblings
beyond the inner ``if``, e.g. ``cli/encrypt/inventory/__main__.py``
where ``encrypted_value = …`` runs regardless of ``ask_confirmation``)
or hurt readability (long literal whitelists).
RUF012 (9): annotated mutable class attributes with ``ClassVar[…]``
and added ``from typing import ClassVar``.
SIM105 (6): ``try/except: pass`` → ``with contextlib.suppress(…):``
with the necessary ``import contextlib`` added. Verified on
``utils/templating.py`` (3 occurrences in a ``finally`` block) —
cleanup behaviour is identical.
SIM115 (4): wrapped ``tempfile.NamedTemporaryFile(..., delete=False)``
call patterns in a ``with`` block; ``delete=False`` keeps the file
on disk after the block, so callers' ``return f.name`` still works.
PERF402, PERF403, B007, SIM108, SIM103: 5 misc fixes (loop→list-copy,
loop→dict-comprehension, unused-loop-var rename, ternary, return
expression).
Verification
============
- ``ruff check .`` → All checks passed
- ``ruff format --check .`` → 894 files already formatted
- 1621 unit tests → all pass
- Integration suite: 44 baseline failures, 1 skipped (unchanged)
- Sample-audited PERF401 rewrites in ``resolver.py``, ``inventory.py``,
``cache/files.py``, ``ressources/__main__.py``; SIM117 in
``test_main.py``; SIM105 in ``utils/templating.py``; SIM102 noqa
rationales in ``cli/encrypt/inventory/__main__.py``.
182 files changed, +993 / −960. With this and the prior strict-rule
commit the branch satisfies 11 ruff rule families end-to-end.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds ``[tool.ruff.lint]`` with ``select = ["E", "F", "I", "B", "UP"]``
and ``target-version = "py311"``. ``E501`` is ignored because
``ruff format`` already reflows what it can; the remaining long lines
are unsplittable string literals / URLs.
Auto-fixed (2753 issues, behavior-preserving):
- UP006/UP035: ``List[X]`` → ``list[X]``, ``Dict[X]`` → ``dict[X]``,
``from typing import …`` redirected where superseded by builtins.
- UP045: ``Optional[X]`` → ``X | None``.
- UP010/UP009/UP015: dropped Python-2-era pragmas (``from __future__
import absolute_import, division, print_function``,
``# -*- coding: utf-8 -*-``), redundant ``open(..., "r")`` mode.
- UP004: dropped implicit ``class X(object):``.
- UP022/UP008/UP032: stdlib idiom modernization (``capture_output``,
super-call simplification, etc.).
- B905: ``zip(...)`` → ``zip(..., strict=False)`` (preserves prior
behavior; switching to ``strict=True`` is a separate semantic
choice not made here).
- I001: import-block ordering.
Hand-fixed (66 issues):
- B904 × 54: appended ``from <bound>`` (when the enclosing ``except``
binds a name) or ``from None`` (when it doesn't) so chained
exceptions stay distinguishable from errors-during-exception-
handling. Applied via a small AST-aware script that walked back to
the enclosing ``except`` clause and matched the binding.
- B017 × 4: narrowed broad ``assertRaises(Exception)`` to the actual
``AnsibleError`` / ``AnsibleFilterError`` raised by the code under
test.
- B023 × 2: bound captured loop variables via default args
(``def _record(name, *, _path_real=path_real, ...)``) so each
closure gets its own snapshot.
- B007 × 2: unused loop control variables renamed to ``_`` /
rewritten to ``.values()``.
- UP007 × 2: ``Union[X, Y]`` → ``X | Y`` (plus dropping the now-unused
``from typing import Union``).
- B018 × 1: Odoo addon manifests are bare dict expressions at module
level by design — suppressed with an inline ``# noqa: B018`` and
rationale comment.
- UP031 × 1: printf-style format → f-string.
The 65 ``# noqa: …`` markers introduced by the auto-fixer all carry
real ruff codes (``B018``, ``E402``, ``F401``, etc.), so
``test_noqa_only_ruff_codes`` stays green.
Verification
============
- ``ruff check .`` → All checks passed
- ``ruff format --check .`` → 894 files already formatted
- ``tests.lint.repository.{test_project_root_import,
test_no_direct_yaml_calls, test_noqa_only_ruff_codes}`` and
``tests.unit.utils.annotations.test_suppress`` → 20 tests OK
- 1621 unit tests → all pass
- Integration suite: 44 failures (pre-existing baseline, none from
this change), 1 skipped.
Going forward, the new rules block regressions on E (style), F
(pyflakes), I (import order), B (likely-bug patterns), and UP
(modernization to py311) for any new code.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The literal `# noqa` substring inside the descriptive comment, even
wrapped in backticks, was caught by ruff's noqa parser. Rephrased to
"via `nocheck`-keyword suppression markers" so ruff no longer matches.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reason: ruff parses every `# noqa: <code>` marker as a flake8 directive
and warns on every non-flake8 rule key. Every catalog rule in
`docs/contributing/actions/testing/suppression.md` is project-specific
(kebab-case), so `# noqa:` markers fired the warning on every parse.
`# nocheck:` is the project synonym ruff ignores.
Scope:
- 41 `roles/*/meta/services.yml` + 1 task + 1 .cfg.j2 template: pure
noqa→nocheck swap for catalog rules. Mixed flake8 markers
(`# noqa: E402, X`) preserved verbatim.
- 5 production .py + 2 production scripts: same swap.
- All catalog-rule lints' docstrings updated; redundant "or # nocheck:"
parentheticals collapsed to a single nocheck form.
- `utils/annotations/suppress.py`: docstring rewritten to spell out
the new convention. Parser still accepts both keywords as synonyms.
- `docs/contributing/actions/testing/suppression.md`: convention
rewritten to mandate `nocheck:`; missing `domain-spot` and
`literal-protocol-lookup` rows added; "enforced by" pointer added.
New guard:
- `tests/lint/repository/test_noqa_only_ruff_codes.py`: meta-lint
failing any `# noqa: <code>` whose codes don't match `[A-Z]+\d+`
(real ruff/flake8 shape). Allowlists the impl, its unit test, the
catalog page, the direct-yaml lint, and itself.
- `tests/lint/repository/test_no_direct_yaml_calls.py`: hardened to
reject the `noqa:` form via inlined regex (no longer routed through
`utils.annotations.suppress`).
Tangential cleanups carried in this same commit:
- `utils/cache/yaml.py`, `utils/handler/yaml.py`: 6 `direct-yaml`
markers swapped.
- `utils/cache/base.py`: `from . import PROJECT_ROOT, ROLES_DIR` moved
up to the import block (E402).
- `cli/create/role/__init__.py`: reordered so PROJECT_ROOT is defined
before `__main__` is imported (also fixed a latent circular import).
- PROJECT_ROOT migration tail: 2 new `__init__.py` modules + 10 test
files swapped from `def repo_root()` to `from . import PROJECT_ROOT`.
- ~20 ruff auto-fix removals of unused `from pathlib import Path`.
Verification:
- `ruff check .` clean.
- 23 suppression-related tests pass.
- The new lint detects all three regression shapes (project rule alone,
mixed with real ruff code, lowercase-only).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drop the second name for the same constant. Every consumer now uses
`PROJECT_ROOT` exclusively, matching the canonical
`utils.cache.__init__.PROJECT_ROOT` source.
The rename is a word-boundary substitution applied across every
Python file in the repo, validated by `ast.parse` after each edit.
Three files additionally drop a redundant
`PROJECT_ROOT = PROJECT_ROOT` class attribute (it pointed the class-
level name at the module-level name) and inline the
`self.PROJECT_ROOT` reads to plain `PROJECT_ROOT`.
The pass also closes a latent wave-1 inconsistency: a handful of
files received the `from . import PROJECT_ROOT` shim earlier but
their consumer expressions still spelled `REPO_ROOT / "scripts" / …`,
so the shim was unused. The rename ties shim and consumer together.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two intertwined changes that share the same touched files:
1. Move every meta-related integration test under
`tests/integration/roles/meta/` into one of three sibling
sub-clusters and shorten the redundant filename prefixes:
- `services/` — `test_services_dynamic_flags.py` →
`test_dynamic_flags.py`, `test_services_no_self_reference.py` →
`test_no_self_reference.py`. The `run_after` cluster moves
under it (it is a `meta/services.yml` concern, not a
stand-alone dependency one): `test_services_explicit.py` keeps
its name, the rest drop the redundant prefix.
- `variants/` — `test_variants_coverage.py` → `test_coverage.py`,
plus the two new lints `test_auth_coverage.py` (LDAP / OIDC /
OAuth2 matrix completeness) and `test_services_match.py`
(every variant override key MUST exist in `services.yml`).
- `dependencies/` — relocates the legacy
`tests/integration/roles/dependencies/` cluster so all
`meta/`-shaped tests live under one parent.
Path-index constants (`Path(__file__).resolve().parents[N]`,
`os.pardir` chains) updated for the deeper nesting.
2. Route every yaml read in the migrated tests through
`utils.cache.yaml`. The single-doc helper `load_yaml_any` covers
most of them; the new `load_yaml_all` mirrors `load_yaml_any` for
the multi-doc case (`yaml.safe_load_all`) and is keyed off the
same `(path, mtime, size)` signature, with parallel `_drop_path`
/ `_reset` housekeeping. `tests/lint/ansible/variables/
test_vars_usage_in_yaml.py` is the first user.
READMEs cleaned up to match the new directory structure (path
crosslinks, level-up counts) and four new READMEs added for the
new sub-clusters under the `docs/` README guidelines (RFC 2119,
emojis after headlines, no em dashes, no SPOT self-labels).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wire the existing `noqa` / `nocheck` grammar into the role/group var
usage lint via `utils.annotations.suppress.is_suppressed_at`. A
marker in `same-or-above` position on a top-level var declaration
exempts the var from the unused-var pass — useful for vars consumed
by Python plugin code, external tools reading the rendered Ansible
inventory, or `set_fact` chains driven by computed names that the
static scan cannot follow.
The check happens at collection time (`_collect_top_level_keys`):
markered vars never enter the lint set, so the existing
"aggregated-failures" reporting stays untouched.
Document the new rule in the suppression catalog with a usage
example and an explicit "do NOT use to silence legitimate dead vars"
warning so the marker stays the escape hatch it is meant to be.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move the existing `test_vars_usage_in_yaml.py` (task-level `vars:`
block scanner) and the new `test_role_and_group_vars_used.py`
(role/group var top-level scanner) into a dedicated
`tests/lint/ansible/variables/` subpackage. The two checks address
orthogonal scopes — task-local `vars:` blocks vs. role-local
`vars/main.yml` / `defaults/main.yml` and `group_vars/**/*.yml` — so
co-locating them documents the variable-lifecycle coverage in one
spot and leaves room for follow-up scanners (host_vars, set_fact,
register, …) without cluttering the parent directory.
The new lint:
- Extracts top-level mapping keys from every
`roles/<role>/{vars,defaults}/main.yml` and
`group_vars/**/*.{yml,yaml}` via the cached `load_yaml_any`,
filtered to identifiers (`[A-Za-z_][A-Za-z0-9_]*`).
- Single project-tree walk over `.yml` / `.yaml` / `.j2` builds two
identifier sets: tokens inside Jinja `{{ … }}` / `{% … %}` blocks,
and identifiers in Ansible expression-bearing keys (`when:`,
`loop:`, `with_*:`). Tokens immediately followed by `(` are
excluded so function/macro calls do not count as variable uses.
- Routes every read through the project caches: `iter_project_files`
(`lru_cache(maxsize=1)`), `read_text` (`lru_cache(maxsize=8192)`),
and `load_yaml_any` (signature-keyed cache). No `yaml.safe_load*`
bypass.
- Whitelists `ansible_python_interpreter` and `ansible_shell_executable`
(Ansible connection-behavior keys consumed by the runner itself).
- Aggregates every undecidable variable into one
`<file>:<line>: '<name>' is never referenced` line and fails once
with the full list, so the reviewer sees the whole drift surface
in a single run.
Surfaces seven existing dead declarations on first run (across
`sys-stk-front-proxy`, `test-e2e-playwright`, `web-app-discourse`,
`web-app-opentalk`, `group_vars/all/`); pruning them is left for
follow-up commits so this one stays purely the lint introduction.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>