8Compare a module's ``Dependencies`` docstring section with what the module
9actually imports. The advisory lint rule ``dependencies_match`` runs this
10on every source module. The section parsing lives in
11:mod:`oct.deps.oct_deps`, whose cycle detection leaves lazy and
12not-imported entries out, so the rule and ``oct deps`` read an entry alike.
14The operator ruled on 2026-09-28 (docs/CHART_C_TRACE_FOLLOWUPS_2026-09-28.md,
15section 8, in the workspace):
171. Every import is declared. An import made only inside a function or
18 under ``TYPE_CHECKING`` is declared with a purpose starting ``lazy``,
19 and ``oct deps`` leaves lazy entries out of its cycle detection.
202. First-party packages (any package in the workspace) and third-party
21 packages are both checked; standard-library modules are optional
22 (declaring one is allowed; it is stale only if nothing of it is
243. The rule runs at every profile, is advisory, and exempts test modules.
26One addition, for the operator to veto: a dependency used without an
27import (a CLI, a ``python -m`` subprocess, a pytest plugin, a module
28loaded by name) is declared with a purpose starting ``not imported``; it
29is never reported stale and never enters cycle detection.
33- :func:`parse_entries` - the ``Dependencies`` entries of a docstring, with
34 their markers, and the lines that are not entries.
35- :func:`collect_imports` - every dependency a module imports, each marked
36 eager or lazy, standard library kept apart.
37- :func:`check_dependencies_match` - the comparison: missing, stale,
38 wrongly marked and unrecognised entries, as one verdict and message.
44 L2 - (none) called once per file by the linter; the linter logs verdicts
45 L3 - workspace roots resolved for a project
46 L4 - per-file comparison totals
50- Never raises: any internal error becomes a passing verdict whose message
51 says the check did not run. A raising rule would make the quality gate
52 skip every rule for the file, the secret check included.
53- A module's dotted name is taken from the workspace subproject root whose
54 first path component is a package, so a file in a namespace directory of
55 a package (``oct/oct/core``) is ``oct.core.git``; outside any package,
56 from Python's own rule.
57- First-party names are checked against the filesystem with exact case
58 (directory listings memoised for one lint run), never by walking the
59 project, so ``oct git status`` stays fast and Windows and Linux agree.
60- An import is recorded as written: ``import a.b.c`` is ``a.b.c``; ``from X
61 import y`` is ``X.y`` when that is a module, else ``X``. Nothing is
62 shortened to whatever prefix exists on disk, so an absent generated
63 module (a setuptools-scm version file) does not change the verdict.
64- A first-party entry must name a module exactly, or exactly what an
65 import names; an attribute (``pkg.mod.Class``) is reported as naming no
66 module. A third-party or standard-library entry may name the package,
67 which covers its submodules.
68- An ``if TYPE_CHECKING:`` body is lazy and its ``else`` eager; ``if not
69 TYPE_CHECKING:`` is the reverse; any other test is eager.
70- An ``oc_diagnostics`` entry is satisfied by an import of oct's shim
71 ``oct.core.diagnostics`` (Core spec: oc_diagnostics "should always be
72 listed if the module uses _dbg").
76- oct.core.diagnostics (_dbg structured logger)
77- oct.core.workspace (lazy; the workspace manifest's subproject roots)
78- oct.deps.import_graph (_is_test_path, _resolve_relative - shared rules)
79- oct.deps.oct_deps (module_name_for, dependency_items, entry_marker - the shared parsing)
82from __future__
import annotations
88from dataclasses
import dataclass, field
89from pathlib
import Path
91from oct.core.diagnostics
import _dbg
96_ENTRY_RE = re.compile(
r"^`?([A-Za-z_][\w.]*)`?\s*\((.*)\)\s*$")
98STDLIB: frozenset[str] = frozenset(sys.stdlib_module_names) | {
"__future__"}
101_SHIM =
"oct.core.diagnostics"
104_NOT_NAMESPACE = frozenset({
"tests",
"test",
"docs",
"__pycache__",
"node_modules",
105 ".venv",
"build",
"dist"})
108@dataclass(frozen=True)
110 """One ``- name (purpose)`` line of a Dependencies section."""
120 """What :func:`check_dependencies_match` found for one module."""
122 missing: list[str] = field(default_factory=list)
123 missing_lazy: list[str] = field(default_factory=list)
124 unmarked_lazy: list[str] = field(default_factory=list)
125 marked_lazy_but_eager: list[str] = field(default_factory=list)
126 marked_not_imported_but_imported: list[str] = field(default_factory=list)
127 stale: list[str] = field(default_factory=list)
128 unrecognised: list[str] = field(default_factory=list)
129 unresolvable: list[str] = field(default_factory=list)
132 def ok(self) -> bool:
133 return not any(vars(self).values())
137 for label, items
in (
140 (
"imported only lazily, mark the entry 'lazy'", self.
unmarked_lazy),
143 (
"stale (not imported)", self.
stale),
148 parts.append(f
"{label}: {', '.join(items)}")
149 return "; ".join(parts)
or "OK"
153 """Return ``(entries, unrecognised lines, section present)`` for *docstring*.
155 The items come from :func:`oct.deps.oct_deps.dependency_items`: a bullet
156 whose text continues on the next line is joined first, so a wrapped
157 purpose still parses and its marker is read from the joined text, as
158 ``oct deps`` reads it.
160 items = dependency_items(docstring)
162 return [], [], bool(re.search(
r"Dependencies\s*\n\s*-+", docstring
or ""))
163 entries, unrecognised = [], []
165 e = _ENTRY_RE.match(item.text)
168 unrecognised.append(text
if len(text) <= 60
else text[:57] +
"...")
170 marker = entry_marker(e.group(2))
171 entries.append(
Entry(e.group(1), e.group(2), marker ==
"lazy", marker ==
"not imported"))
172 return entries, unrecognised,
True
179 """Exact-case filesystem probes, memoised in a dict the caller owns.
181 The linter passes ``LinterContext.dependency_memo``, which lives exactly
182 as long as one lint run, so a long-lived process (the MCP server) sees
183 files created between runs, and nothing needs resetting. The memo is
184 shared by the linter's worker threads; a race only repeats a probe.
189 self.
_listings: dict[Path, frozenset[str]] = memo.setdefault(
"listings", {})
190 self.
_modules: dict[tuple[Path, str, bool], bool] = memo.setdefault(
"modules", {})
192 def listing(self, directory: Path) -> frozenset[str]:
196 got = frozenset(os.listdir(directory))
202 def is_dir(self, parent: Path, name: str) -> bool:
203 return name
in self.
listing(parent)
and (parent / name).
is_dir()
206 return "__init__.py" in self.
listing(directory)
208 def module_exists(self, root: Path, dotted: str, namespace_top: bool =
False) -> bool:
209 """Whether *dotted* is a module under *root*, with exact case.
211 A directory counts as a (namespace) package below the top level; at
212 the top level only with an ``__init__.py``, unless *namespace_top*.
214 key = (root, dotted, namespace_top)
222 parts = dotted.split(
".")
224 for part
in parts[:-1]:
225 if not self.
is_dir(d, part):
229 if leaf +
".py" in self.
listing(d):
232 return self.
is_package(d / leaf)
or len(parts) > 1
or namespace_top
237 """Every subproject root of the workspace holding *project_root*, then it."""
238 func =
"_workspace_roots"
239 cached = probe.memo.setdefault(
"roots", {}).get(project_root)
240 if cached
is not None:
242 roots: list[Path] = [project_root]
244 from oct.core.workspace
import find_workspace_root, load_workspace
245 ws_root = find_workspace_root(project_root)
246 if ws_root
is not None:
247 roots = [sp.path.resolve()
for sp
in load_workspace(ws_root).subprojects] + roots
248 except Exception
as exc:
249 _dbg(
"OCT-DEPS", func, f
"workspace roots unavailable ({exc!r}); project only", 3)
250 ordered = tuple(sorted(dict.fromkeys(roots), key=
lambda r: len(r.parts), reverse=
True))
251 _dbg(
"OCT-DEPS", func, f
"{project_root}: {len(ordered)} roots", 3)
252 probe.memo[
"roots"][project_root] = ordered
256def _naming_root(py_file: Path, roots: tuple[Path, ...], probe: _Probe) -> tuple[Path, bool]:
257 """The directory *py_file*'s dotted name is relative to, and whether it is packaged.
259 Preferred: the most specific workspace root under which the file's first
260 path component is a package. Otherwise Python's rule from the file's
261 own directory; ``False`` then means a script outside any package.
265 rel = py_file.relative_to(r)
268 if len(rel.parts) > 1
and probe.is_package(r / rel.parts[0]):
271 if not probe.is_package(d):
273 while d.parent != d
and probe.is_package(d.parent):
275 return d.parent,
True
279 """First-party names: workspace packages, plus a script's local modules.
281 *local_roots* are the directories a script outside any package can
282 import from: its own directory (``sys.path[0]``) and, as the runners
283 in this workspace arrange, the one above. Their modules and their
284 namespace directories (holding ``.py`` files, not tests or docs) are
288 def __init__(self, roots: tuple[Path, ...], local_roots: tuple[Path, ...],
289 probe: _Probe) ->
None:
295 self.
tops.update(n
for n
in probe.listing(r)
if probe.is_package(r / n))
296 for r
in local_roots:
297 for n
in probe.listing(r):
298 if n.endswith(
".py"):
299 self.
tops.add(n[:-3])
300 elif (n
not in _NOT_NAMESPACE
and not n.startswith(
".")
and probe.is_dir(r, n)
301 and any(x.endswith(
".py")
for x
in probe.listing(r / n))):
305 return dotted.split(
".")[0]
in self.
tops
308 return (any(self.
probe.module_exists(r, dotted)
for r
in self.
roots)
309 or any(self.
probe.module_exists(r, dotted,
True)
for r
in self.
local_roots))
313 probe: _Probe) -> _Resolver:
314 resolvers = probe.memo.setdefault(
"resolvers", {})
315 got = resolvers.get((roots, local_roots))
317 got = resolvers[(roots, local_roots)] =
_Resolver(roots, local_roots, probe)
325 """``"body"`` for ``if TYPE_CHECKING:``, ``"orelse"`` for ``if not TYPE_CHECKING:``."""
326 def is_tc(node: ast.expr) -> bool:
327 return ((isinstance(node, ast.Name)
and node.id ==
"TYPE_CHECKING")
328 or (isinstance(node, ast.Attribute)
and node.attr ==
"TYPE_CHECKING"))
331 if isinstance(test, ast.UnaryOp)
and isinstance(test.op, ast.Not)
and is_tc(test.operand):
337 """Every import statement with whether it is lazy, in one linear pass."""
338 out: list[tuple[ast.Import | ast.ImportFrom, bool]] = []
339 stack: list[tuple[ast.AST, bool]] = [(stmt,
False)
for stmt
in tree.body]
341 node, lazy = stack.pop()
342 if isinstance(node, (ast.Import, ast.ImportFrom)):
343 out.append((node, lazy))
344 elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
345 stack.extend((child,
True)
for child
in node.body)
346 elif isinstance(node, ast.If):
348 stack.extend((child, lazy
or kind ==
"body")
for child
in node.body)
349 stack.extend((child, lazy
or kind ==
"orelse")
for child
in node.orelse)
351 stack.extend((child, lazy)
for child
in ast.iter_child_nodes(node)
352 if isinstance(child, (ast.stmt, ast.excepthandler, ast.match_case)))
357 resolver: _Resolver) -> tuple[dict[str, bool], set[str]]:
358 """``(non-stdlib dependency -> imported eagerly, standard-library names)``.
360 Names are recorded as written: ``import a.b`` is ``a.b``; ``from X import
361 y`` is ``X.y`` when that is a first-party module, else ``X``.
363 found: dict[str, bool] = {}
364 stdlib: set[str] = set()
366 def add(name: str, eager: bool) ->
None:
367 if name
and name != module:
368 found[name] = found.get(name,
False)
or eager
372 if isinstance(node, ast.Import):
373 for alias
in node.names:
374 if alias.name.split(
".")[0]
in STDLIB:
375 stdlib.add(alias.name)
377 add(alias.name, eager)
380 base = _resolve_relative(module, is_package, node.level, node.module)
384 base = node.module
or ""
387 if base.split(
".")[0]
in STDLIB:
390 if resolver.is_first_party(base):
391 for alias
in node.names:
392 sub = f
"{base}.{alias.name}"
393 add(sub
if alias.name !=
"*" and resolver.exists(sub)
else base, eager)
402def _covers(entry: str, dep: str, first_party: bool) -> bool:
405 return not first_party
and dep.startswith(entry +
".")
409 return (_is_test_path(py_file, naming_root)
or _is_test_path(py_file, project_root)
410 or py_file.name.startswith(
"test_")
or py_file.name ==
"conftest.py")
414 """A declared standard-library entry is satisfied by an import of it,
415 of a submodule, or of its package (``import os`` gives ``os.path``)."""
416 return any(
_covers(entry, s,
False)
or _covers(s, entry,
False)
for s
in stdlib)
420 docstring: str |
None =
None, *,
421 memo: dict |
None =
None) -> tuple[bool, str]:
422 """The ``dependencies_match`` verdict for one file: ``(ok, message)``.
424 *docstring* defaults to the module docstring of *tree*. *memo* holds the
425 filesystem probes across calls (the linter passes one per run); without
426 it every call probes afresh. Test modules are exempt. Never raises.
428 func =
"check_dependencies_match"
430 probe =
_Probe(memo
if memo
is not None else {})
431 py_file = py_file.resolve()
432 project_root = project_root.resolve()
436 return True,
"exempt: test module"
438 return True,
"not run: the file does not parse (syntax_error reports it)"
439 if docstring
is None:
440 docstring = ast.get_docstring(tree, clean=
False)
or ""
441 local = ()
if packaged
and naming
in roots
else tuple(dict.fromkeys((naming, naming.parent)))
442 resolver =
_resolver_for(roots
if naming
in roots
else roots + (naming,), local, probe)
443 module = module_name_for(py_file, naming)
444 imports, stdlib =
collect_imports(tree, module, py_file.name ==
"__init__.py", resolver)
447 f =
Findings(unrecognised=unrecognised)
448 declared: list[tuple[Entry, bool]] = []
450 fp = resolver.is_first_party(e.name)
451 if fp
and not e.not_imported
and e.name
not in imports
and not resolver.exists(e.name):
452 f.unresolvable.append(e.name)
454 declared.append((e, fp))
459 id(e): any(imports[d]
and _covers(e.name, d, fp)
for d
in imports)
460 for e, fp
in declared
462 for dep, eager
in sorted(imports.items()):
463 covering = [e
for e, fp
in declared
if _covers(e.name, dep, fp)]
464 if covering
and all(e.not_imported
for e
in covering):
465 f.marked_not_imported_but_imported.append(dep)
467 (f.missing
if eager
else f.missing_lazy).append(dep)
468 elif not eager
and not any(e.lazy
or e.not_imported
or covers_eager[id(e)]
for e
in covering):
469 f.unmarked_lazy.append(dep)
470 elif eager
and all(e.lazy
for e
in covering):
471 f.marked_lazy_but_eager.append(dep)
472 for e, fp
in declared:
475 if any(
_covers(e.name, dep, fp)
for dep
in imports):
479 if e.name ==
"oc_diagnostics" and _SHIM
in imports:
481 f.stale.append(e.name)
482 _dbg(
"OCT-DEPS", func, f
"{module}: imports={len(imports)} entries={len(entries)} ok={f.ok}", 4)
483 return f.ok, f.message()
484 except Exception
as exc:
485 return True, f
"not run: internal error ({type(exc).__name__}: {exc})"
492 "check_dependencies_match",
bool _module_exists(self, Path root, str dotted, bool namespace_top)
dict[tuple[Path, str, bool], bool] _modules
dict[Path, frozenset[str]] _listings
frozenset[str] listing(self, Path directory)
bool is_package(self, Path directory)
bool is_dir(self, Path parent, str name)
bool module_exists(self, Path root, str dotted, bool namespace_top=False)
None __init__(self, dict memo)
None __init__(self, tuple[Path,...] roots, tuple[Path,...] local_roots, _Probe probe)
bool exists(self, str dotted)
bool is_first_party(self, str dotted)
list marked_lazy_but_eager
list marked_not_imported_but_imported
tuple[Path,...] _workspace_roots(Path project_root, _Probe probe)
bool _covers(str entry, str dep, bool first_party)
tuple[dict[str, bool], set[str]] collect_imports(ast.Module tree, str module, bool is_package, _Resolver resolver)
bool _is_test_module(Path py_file, Path naming_root, Path project_root)
bool _stdlib_satisfied(str entry, set[str] stdlib)
list[tuple[ast.Import|ast.ImportFrom, bool]] _imports_with_laziness(ast.Module tree)
tuple[bool, str] check_dependencies_match(Path py_file, Path project_root, ast.Module|None tree, str|None docstring=None, *, dict|None memo=None)
tuple[list[Entry], list[str], bool] parse_entries(str docstring)
tuple[Path, bool] _naming_root(Path py_file, tuple[Path,...] roots, _Probe probe)
_Resolver _resolver_for(tuple[Path,...] roots, tuple[Path,...] local_roots, _Probe probe)
str|None _type_checking_kind(ast.expr test)