Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions docs/adr/defadr_doeff_adr_001_wiring_selfcheck.hy
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
;;; Executable ADR: defadr の存在と pytest collection の配線を自己検査する。

(require doeff-adr.macros [defadr defsemgrep rule law])
(import doeff-adr.macros [fact interpretation counterexample])


(defadr ADR-DOE-ADR-001
:title "実行可能 ADR の存在と実際の pytest collection を全量照合する"
:status "accepted"
:scope ["doeff-adr" "pytest collection" "CI wiring"]
:problem
[(fact
"doeff・proboscis-ema・agent-control-plane の3リポジトリで defadr_*.hy が存在しても testpaths または CI 引数の外に置かれ、検査が無音で不活性化した。"
:evidence "docs/crystallization/erosion-audit-2026-07-02.md")
(fact
"pytest_collect_file は pytest が走査したファイルだけを受け取るため、collection root 外の defadr をプラグイン単独では従来発見できなかった。"
:evidence "packages/doeff-adr/src/doeff_adr/pytest_plugin.py")]
:context
[(interpretation
"設定ファイルを静的に推測するより、リポジトリ全体の候補と session.items の実測値を比較すれば testpaths・明示引数・ignore hook を同じ規則で扱える。")
(interpretation
"既存利用者への導入時の誤爆を避けるため通常 pytest は警告とし、CI 用コマンドだけを strict にする。")]
:decision
[(rule R1 "pytest collection 完了時に、リポジトリ内の実行可能 ADR 候補と実際に収集された item のファイル集合を照合する。")
(rule R2 "既定モードは warn とし、strict では未収集 ADR を列挙して非ゼロ終了する。明示的な off は局所実行用に残す。")
(rule R3 "doeff-adr verify-wiring は strict の collect-only pytest を起動する。外部リポジトリは CI 一行ゲートとして使える。doeff 自身は pytest 正典(ADR-DOE-ENFORCE-001 R1)に従い tests/test_adr_wiring_gate.py で既定ゲートに常時組み込む。")
(rule R4 "defsemgrep は semgrep executable 不在を skip にせず fail-closed のまま維持する。wiring strict とは別の実行時依存検査として扱う。")]
:laws
[(law every-executable-adr-is-collected
:statement "exists(defadr_file) => collected_by_configured_pytest_scope(defadr_file)"
:counterexamples
[(counterexample "docs/adr/defadr_*.hy が存在するが testpaths は tests のみ")
(counterexample "CI が tests だけを明示して docs/adr を走査しない")])
(law strict-wiring-fails-closed
:statement "uncollected_defadr and wiring_mode(strict) => nonzero_exit_with_paths"
:counterexamples
[(counterexample "未収集 ADR があっても collected test の成功だけで CI が緑になる")])]
:enforcement
[(defsemgrep no-silent-off-wiring-default
:languages ["generic"]
:pattern "default=\"off\""
:message "doeff-adr wiring must not silently default to off."
:bad ["parser.addini(\"doeff_adr_wiring\", default=\"off\")"]
:good ["parser.addini(\"doeff_adr_wiring\", default=\"warn\")"])]
:plans ["packages/doeff-adr/tests/test_wiring.py"
"packages/doeff-adr/README.md"
"tests/test_adr_wiring_gate.py"
"docs/doeff-2026-07-14-agent-first-investment-architecture-plan.md"])
10 changes: 6 additions & 4 deletions docs/adr/defadr_doeff_domain_001_vocabulary_cohesion.hy
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,13 @@
(counterexample "新規エージェントが既存 domain 語彙を発見できず、同義の effect 型を別パッケージに追加する — 照会面(SEDA)不在時の既定挙動")])]
:enforcement
[(deftest test-adr-doe-domain-001-defdomain-exists
;; RED(2026-07-14): defdomain マクロは未実装。R1 実装で green。
;; ソーステキストのピン(マクロは module 属性にならないため)。実装後は挙動ピンに昇格する
;; designed-red を xfail(strict) 相当で表現: 未実装の間は xfail、
;; 緑化したら fail して解除を強制
(import pathlib [Path])
(import pytest)
(setv root (get (. (Path __file__) parents) 2))
(setv macros-src (.read-text (/ root "packages" "doeff-hy" "src" "doeff_hy" "macros.hy")))
(assert (in "defmacro defdomain" macros-src)
"defdomain マクロが doeff-hy に存在しない — ADR-DOE-DOMAIN-001 R1"))]
(when (in "defmacro defdomain" macros-src)
(pytest.fail "defdomain が実装された — ADR-DOE-DOMAIN-001 の designed-red を解除し、挙動ピンに昇格して記帳すること(E1)"))
(pytest.xfail "defdomain マクロは未実装(E1 待ち)— ADR-DOE-DOMAIN-001 R1"))]
:plans ["docs/doeff-2026-07-14-agent-first-investment-architecture-plan.md"])
6 changes: 3 additions & 3 deletions docs/adr/enforcement-ledger.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"_comment": "ADR-DOE-ENFORCE-001 R5 anti-drop ratchet の台帳。enforcement 資産の数が黙って減る(または黙って増える)ことを tests/test_enforcement_ledger.py が禁止する。数を変える変更は、この台帳の明示的な更新を同じ変更セットに含めること。",
"defadr_files": 15,
"defadr_files": 16,
"semgrep_rules": 229,
"adr_deftest_enforcements": 8,
"adr_defsemgrep_enforcements": 19,
"adr_laws": 49
"adr_defsemgrep_enforcements": 20,
"adr_laws": 51
}
33 changes: 33 additions & 0 deletions packages/doeff-adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ The package provides Hy macros for:
- `deftest`: re-exported from `doeff-hy` for ADR-local executable examples.
- pytest plugin: collect `defadr_*.hy` / `test_defadr_*.hy` executable ADR
files and run generated `test_*` functions.
- wiring self-check: compare every executable ADR in the repository with the
files that the current pytest invocation actually collected.

Accepted ADRs must carry at least one executable enforcement.

Expand All @@ -19,6 +21,37 @@ Run executable ADR files directly:
uv run pytest docs/adr/defadr_0018_hypha_app_in_doeff_hy.hy
```

## Collection wiring gate

Include every executable ADR directory in the pytest collection roots used by
CI. For the conventional layout:

```toml
[tool.pytest.ini_options]
testpaths = ["tests", "docs/adr"]
```

Then add the one-line fail-closed gate before the test run:

```bash
uv run doeff-adr verify-wiring
```

The command runs pytest collection in `strict` mode and exits nonzero while any
matching executable ADR is absent from the effective collection. Normal pytest
runs default to `warn` so targeted local runs remain usable. Set
`doeff_adr_wiring = "strict"` in pytest configuration when every invocation
should fail closed, or pass `--doeff-adr-wiring=off` only for an intentional
local opt-out. `defsemgrep` enforcement separately fails, rather than skips,
when the `semgrep` executable is unavailable.

For proboscis-ema and agent-control-plane follow-ups, add their existing
`docs/adr` directory to the pytest roots used by the real CI test command,
install `semgrep` when those ADRs contain `defsemgrep`, and run
`uv run doeff-adr verify-wiring` as a required step. The gate reports every
remaining `defadr_*.hy` path, so migration can be completed without maintaining
a second hand-written ADR file list.

If an ADR uses `deftest`, the consuming project must provide the
`doeff_interpreter` pytest fixture in its own `conftest.py` or test shim.
`doeff-adr` intentionally does not choose an interpreter, scheduler, handler
Expand Down
4 changes: 4 additions & 0 deletions packages/doeff-adr/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,13 @@ requires-python = ">=3.10"
dependencies = [
"doeff-hy",
"hy>=1.2.0",
"pytest>=8.1.1",
"pyyaml>=6.0",
]

[project.scripts]
doeff-adr = "doeff_adr.cli:main"

[tool.hatch.build.targets.wheel]
packages = ["src/doeff_adr"]

Expand Down
48 changes: 48 additions & 0 deletions packages/doeff-adr/src/doeff_adr/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""Command-line entry point for doeff-adr repository checks."""

import argparse
import subprocess
import sys
from collections.abc import Sequence


def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="doeff-adr")
subparsers = parser.add_subparsers(dest="command", required=True)
verify_wiring = subparsers.add_parser(
"verify-wiring",
help="Fail when an executable ADR is outside the effective pytest collection scope.",
)
verify_wiring.add_argument(
"pytest_args",
nargs=argparse.REMAINDER,
help="Optional pytest paths or collection arguments.",
)
return parser


def main(argv: Sequence[str] | None = None) -> int:
arguments = _parser().parse_args(argv)
if arguments.command == "verify-wiring":
command: list[str] = [
sys.executable,
"-m",
"pytest",
"--collect-only",
"-q",
*arguments.pytest_args,
"--doeff-adr-wiring=strict",
]
completed: subprocess.CompletedProcess[str] = subprocess.run(
command,
check=False,
capture_output=True,
text=True,
)
if completed.returncode == 0:
print("doeff-adr wiring verified: every executable ADR was collected.")
return 0
sys.stdout.write(completed.stdout)
sys.stderr.write(completed.stderr)
return completed.returncode
raise AssertionError(f"unhandled doeff-adr command: {arguments.command}")
103 changes: 97 additions & 6 deletions packages/doeff-adr/src/doeff_adr/pytest_plugin.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
"""Pytest plugin for executable ADR Hy files."""


import fnmatch
import importlib
import importlib.util
import os
import sys
import warnings
from pathlib import Path
from typing import Any
from typing import Any, Literal

import doeff_hy # noqa: F401 - registers Hy import hooks
import pytest
Expand All @@ -18,6 +19,25 @@
"docs/adr/defadr_*.hy",
"docs/adrs/defadr_*.hy",
)
IGNORED_DISCOVERY_DIRECTORIES = frozenset(
{
".git",
".hg",
".mypy_cache",
".pytest_cache",
".ruff_cache",
".svn",
".tox",
".venv",
"__pycache__",
"build",
"dist",
"node_modules",
"venv",
}
)
WiringMode = Literal["off", "warn", "strict"]
WIRING_MODES = frozenset({"off", "warn", "strict"})


def pytest_addoption(parser: pytest.Parser) -> None:
Expand All @@ -27,6 +47,17 @@ def pytest_addoption(parser: pytest.Parser) -> None:
type="linelist",
default=[],
)
parser.addini(
"doeff_adr_wiring",
"How to report executable ADR files that pytest did not collect: off, warn, or strict.",
default="warn",
)
parser.addoption(
"--doeff-adr-wiring",
choices=sorted(WIRING_MODES),
default=None,
help="Override doeff-adr wiring verification mode (off, warn, or strict).",
)


def pytest_collect_file(file_path: Any, parent: pytest.Collector) -> pytest.Collector | None:
Expand All @@ -38,6 +69,23 @@ def pytest_collect_file(file_path: Any, parent: pytest.Collector) -> pytest.Coll
return DoeffAdrHyFile.from_parent(parent, path=path)


def pytest_collection_finish(session: pytest.Session) -> None:
mode = _wiring_mode(session.config)
if mode == "off":
return
root = Path(session.config.rootpath)
patterns = _file_patterns(session.config)
executable_adrs = _discover_executable_adrs(root, patterns)
collected_files = {Path(item.path).resolve() for item in session.items}
uncollected_adrs = sorted(executable_adrs - collected_files)
if not uncollected_adrs:
return
message = _wiring_message(root, uncollected_adrs, mode)
if mode == "strict":
raise pytest.UsageError(message)
warnings.warn(pytest.PytestWarning(message), stacklevel=1)


class DoeffAdrHyFile(pytest.File):
def collect(self) -> Any:
module = _import_hy_file(self.path, self.config.rootpath)
Expand All @@ -58,13 +106,56 @@ def _coerce_path(path: Any) -> Path:

def _should_collect_hy_file(path: Path, config: pytest.Config) -> bool:
root = Path(config.rootpath)
patterns = [*DEFAULT_FILE_PATTERNS, *config.getini("doeff_adr_hy_files")]
patterns = _file_patterns(config)
return _matches_file_patterns(path, root, patterns)


def _file_patterns(config: pytest.Config) -> tuple[str, ...]:
return (*DEFAULT_FILE_PATTERNS, *config.getini("doeff_adr_hy_files"))


def _matches_file_patterns(path: Path, root: Path, patterns: tuple[str, ...]) -> bool:
rel = _relative_posix(path, root)
candidates = {path.name, rel, path.as_posix()}
return any(
fnmatch.fnmatch(candidate, pattern)
for pattern in patterns
for candidate in candidates
fnmatch.fnmatch(candidate, pattern) for pattern in patterns for candidate in candidates
)


def _wiring_mode(config: pytest.Config) -> WiringMode:
command_line_mode = config.getoption("doeff_adr_wiring")
configured_mode = command_line_mode or config.getini("doeff_adr_wiring")
if configured_mode == "off":
return "off"
if configured_mode == "warn":
return "warn"
if configured_mode == "strict":
return "strict"
choices = ", ".join(sorted(WIRING_MODES))
raise pytest.UsageError(f"doeff_adr_wiring must be one of {choices}; got {configured_mode!r}")


def _discover_executable_adrs(root: Path, patterns: tuple[str, ...]) -> set[Path]:
executable_adrs: set[Path] = set()
for directory, directory_names, file_names in os.walk(root):
directory_names[:] = sorted(
name for name in directory_names if name not in IGNORED_DISCOVERY_DIRECTORIES
)
for file_name in sorted(file_names):
path = Path(directory, file_name)
if path.suffix == ".hy" and _matches_file_patterns(path, root, patterns):
executable_adrs.add(path.resolve())
return executable_adrs


def _wiring_message(root: Path, paths: list[Path], mode: WiringMode) -> str:
outcome = "failed" if mode == "strict" else "warning"
rendered_paths = "\n".join(f" - {_relative_posix(path, root)}" for path in paths)
return (
f"doeff-adr wiring verification {outcome}: executable ADR files exist but were not "
f"collected:\n{rendered_paths}\n"
"Add their directories to pytest testpaths or the CI pytest arguments. "
"Use doeff_adr_wiring=off only for an intentional opt-out."
)


Expand Down
23 changes: 23 additions & 0 deletions packages/doeff-adr/tests/test_defadr_macros.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import sys
import textwrap

import doeff_adr.registry
import doeff_hy # noqa: F401 - registers Hy import hooks
import pytest
from doeff_adr.registry import SemgrepSpec, clear_registry, get_adr, get_enforcement
Expand Down Expand Up @@ -194,6 +195,28 @@ def test_expected_red_defsemgrep_still_validates_rule_fixtures(tmp_hy_dir):
mod.test_expected_red_rule_defsemgrep()


def test_defsemgrep_fails_when_semgrep_executable_is_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def missing_executable(_command: str) -> None:
return None

clear_registry()
doeff_adr.registry.register_semgrep_enforcement(
"missing_semgrep",
pattern="forbidden-token",
bad=["forbidden-token"],
good=["allowed-token"],
)
monkeypatch.setattr(doeff_adr.registry.shutil, "which", missing_executable)

try:
with pytest.raises(AssertionError, match="semgrep executable is required"):
doeff_adr.registry.assert_semgrep_enforcement("missing_semgrep")
finally:
clear_registry()


def test_pytest_plugin_collects_defadr_hy_files(pytester):
pytester.makeconftest(
"""\
Expand Down
Loading