diff --git a/.github/workflows/python-quality.yml b/.github/workflows/python-quality.yml index 51252f86..7362ade3 100644 --- a/.github/workflows/python-quality.yml +++ b/.github/workflows/python-quality.yml @@ -239,6 +239,53 @@ jobs: - uses: astral-sh/setup-uv@v6 - run: uv run --quiet --frozen --no-dev --project plugins/capability/darrow-tickets-github/backend python plugins/capability/darrow-tickets-github/backend/tests/fresh_install.py + decisions-windows: + name: Decisions Python ${{ matrix.python-version }} on windows-latest + needs: changes + if: contains(needs.changes.outputs.packages, 'plugins/foundation/darrow-decisions/backend') + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + runs-on: windows-latest + defaults: + run: + shell: bash + env: + UV_PYTHON: ${{ matrix.python-version }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + - run: scripts/check-python --package plugins/foundation/darrow-decisions/backend + + decisions-fresh-install: + name: Fresh install decisions on ${{ matrix.os }} + needs: changes + if: contains(needs.changes.outputs.packages, 'plugins/foundation/darrow-decisions/backend') + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - uses: astral-sh/setup-uv@v6 + - run: uv run --quiet --frozen --no-dev --project plugins/foundation/darrow-decisions/backend python plugins/foundation/darrow-decisions/backend/tests/fresh_install.py + - name: Verify decision CLI regression scenarios + if: runner.os != 'Windows' + shell: bash + run: | + /bin/bash --version + /bin/bash plugins/foundation/darrow-decisions/backend/tests/decision.test.sh + inventory: name: Python inventory guard runs-on: ubuntu-latest @@ -376,6 +423,8 @@ jobs: - git-fresh-install - tickets-windows - tickets-fresh-install + - decisions-windows + - decisions-fresh-install - inventory - skill-authoring-fresh-install - observability-fresh-install @@ -409,6 +458,9 @@ jobs: TICKETS_SELECTED: ${{ contains(needs.changes.outputs.packages, 'plugins/capability/darrow-tickets-github/backend') }} TICKETS_WINDOWS_RESULT: ${{ needs.tickets-windows.result }} TICKETS_INSTALL_RESULT: ${{ needs.tickets-fresh-install.result }} + DECISIONS_SELECTED: ${{ contains(needs.changes.outputs.packages, 'plugins/foundation/darrow-decisions/backend') }} + DECISIONS_WINDOWS_RESULT: ${{ needs.decisions-windows.result }} + DECISIONS_INSTALL_RESULT: ${{ needs.decisions-fresh-install.result }} run: | scripts/verify-python-quality-results \ "$CHANGES_RESULT" "$INVENTORY_RESULT" \ @@ -428,3 +480,7 @@ jobs: if [ "$TICKETS_SELECTED" = true ]; then expected=success; fi test "$TICKETS_WINDOWS_RESULT" = "$expected" test "$TICKETS_INSTALL_RESULT" = "$expected" + expected=skipped + if [ "$DECISIONS_SELECTED" = true ]; then expected=success; fi + test "$DECISIONS_WINDOWS_RESULT" = "$expected" + test "$DECISIONS_INSTALL_RESULT" = "$expected" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1c917de1..1345f6c2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -93,7 +93,7 @@ bun run check:python `check:decisions` rejects missing or stale checked-in ADR catalog data. When ADRs change, refresh it first with -`bash plugins/foundation/darrow-decisions/bin/decision catalog rebuild --repo .`. +`uv run --quiet --frozen --no-dev --project plugins/foundation/darrow-decisions/backend darrow-decision catalog rebuild --repo .`. `check:python` is required when Python source, tests, package metadata, or locks change; its standards and package inventory are defined in [`docs/specs/python-quality.md`](docs/specs/python-quality.md). diff --git a/docs/specs/decision-management.md b/docs/specs/decision-management.md index 4e17f306..65d2fc77 100644 --- a/docs/specs/decision-management.md +++ b/docs/specs/decision-management.md @@ -115,8 +115,9 @@ canonical `Summary`, `Context`, `Decision`, and `Consequences`. `Supersedes`, ## Deterministic decision facade -The plugin ships a Bash `decision` facade at `bin/decision`. It inventories ADRs, -allocates the next identifier, validates structure and relationships, checks +The plugin ships a contained Python + UV `darrow-decision` facade, invoked with +`uv run --quiet --frozen --no-dev --project /backend darrow-decision`. +It inventories ADRs, allocates the next identifier, validates structure and relationships, checks transitions, and emits bounded model-facing output. It does not decide whether a choice is authoritative, material, or ADR-worthy. @@ -128,17 +129,18 @@ choice is authoritative, material, or ADR-worthy. characters are refused rather than followed or silently omitted. - **DM-14 — Collision-safe numbering.** The next identifier follows the highest numeric `ADR-NNNN` identifier found in filenames or headings and preserves at - least four digits. Numeric identifiers are limited to 18 digits so Bash can - compare them without overflow; wider identifiers and exhaustion of that range - are explicit errors. Duplicate identifiers, filename/heading disagreement, + least four digits. Numeric identifiers retain the public 18-digit limit; + wider identifiers and exhaustion of that range are explicit errors. + Duplicate identifiers, filename/heading disagreement, and an already occupied proposed path are errors. - **DM-15 — Structural validation.** Every ADR file must be readable and have a supported identifier, non-empty title, supported status, ISO date, exactly one non-empty canonical `Summary` metadata field, and non-empty `Context`, `Decision`, and `Consequences` sections. Required input that exists but is unreadable causes a clear refusal rather than being - skipped. Under the facade's `LC_ALL=C` byte semantics, ADR titles are capped - at 240 bytes, status and date fields at 32 bytes each, `Summary` at 500 bytes, + skipped. Metadata limits count UTF-8 bytes, preserving the facade's byte + semantics. ADR titles are capped at 240 bytes, status and date fields at + 32 bytes each, `Summary` at 500 bytes, `Supersedes` and `Superseded by` at 1,000 bytes each and 50 targets per field, and `Revisit when` at 500 bytes. @@ -152,10 +154,13 @@ when` at 500 bytes. repository with no symlink component and emits its verified absolute path for model-facing reports. The skills make deliberate repository edits with the harness's ordinary file tools, then use the facade to verify them. -- **DM-18 — Portable shell behavior.** The facade and tests support Bash 5 and - macOS `/bin/bash` 3.2, remain bounded on large input, and follow the repository - shell-portability rules. Inventory and relationship validation are linear in - records plus relationships subject to documented metadata and output caps. +- **DM-18 — Native portable behavior.** The contained Python package supports + Python 3.10–3.13 on native Windows, Linux, and macOS, without runtime Bash + shims or sibling-plugin dependencies. Commands, records, diagnostics, and exit + codes remain stable. Filesystem operations use native paths; catalog writes + close and flush a sibling temporary file before atomic replacement. Inventory + and relationship validation are linear in records plus relationships subject + to documented metadata and output caps. CLI title input is capped at 200 bytes, search input at 200 bytes, list output at 200 records, and validation output at 40 diagnostics plus one omitted-count line. `canonical-path --path` input is capped at 1,000 bytes. Paths and @@ -188,9 +193,9 @@ when` at 500 bytes. complete Markdown catalog atomically in byte-stable order, and `catalog check` verifies its format, directory membership, fingerprints, visible metadata, and source ADR validity. - Rebuilding an unchanged ADR set produces identical bytes under Bash 3.2 and - Bash 5. Repository validation includes the same freshness check, so CI and - manual validation reject drift. The catalog remains local to its ADR owner and + Rebuilding an unchanged ADR set produces identical UTF-8 bytes on every + supported platform. Repository validation includes the same freshness check, + so CI and manual validation reject drift. The catalog remains local to its ADR owner and never catalogs specifications, policies, work items, review state, or another owner surface. @@ -205,6 +210,11 @@ that is unavailable, report the owner and stop without creating a substitute. ### Invariants +- **DM-C0 — Route requested captures before judging authority.** A request to + capture or accept a decision selects this capability even when the choice is + unresolved or its authority is missing. Those facts determine whether to ask + or refuse within the capability; they do not make the request a different + intent. - **DM-C1 — Search before mutation.** Inspect the repository and search related ADRs, specifications, policies, and work-item context before creating or changing a record. Reported root surfaces are discovery candidates, not an diff --git a/package.json b/package.json index de1a4b4e..255aa71a 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "scripts": { "check:docs": "bun scripts/check-docs.ts", "check:docs:external": "bun scripts/check-docs.ts --external", - "check:decisions": "bash plugins/foundation/darrow-decisions/bin/decision validate --repo .", + "check:decisions": "uv run --quiet --frozen --no-dev --project plugins/foundation/darrow-decisions/backend darrow-decision validate --repo .", "check:python": "scripts/check-python", "check:python:inventory": "scripts/test-python-inventory", "check:python:performance": "plugins/capability/darrow-observability-langfuse/tests/performance.test.sh", diff --git a/plugins/foundation/darrow-decisions/.claude-plugin/plugin.json b/plugins/foundation/darrow-decisions/.claude-plugin/plugin.json index b93ebb1d..d8c1014c 100644 --- a/plugins/foundation/darrow-decisions/.claude-plugin/plugin.json +++ b/plugins/foundation/darrow-decisions/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "darrow-decisions", "description": "Capture and query durable repository decisions at their canonical scope", - "version": "0.1.6", + "version": "0.2.0", "license": "BUSL-1.1" } diff --git a/plugins/foundation/darrow-decisions/.codex-plugin/plugin.json b/plugins/foundation/darrow-decisions/.codex-plugin/plugin.json index 0f199b14..0438d069 100644 --- a/plugins/foundation/darrow-decisions/.codex-plugin/plugin.json +++ b/plugins/foundation/darrow-decisions/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "darrow-decisions", - "version": "0.1.6", + "version": "0.2.0", "description": "Capture and query durable repository decisions at their canonical scope", "author": { "name": "Björn Rochel", diff --git a/plugins/foundation/darrow-decisions/README.md b/plugins/foundation/darrow-decisions/README.md index d5966570..8a26f5eb 100644 --- a/plugins/foundation/darrow-decisions/README.md +++ b/plugins/foundation/darrow-decisions/README.md @@ -28,9 +28,9 @@ queries while reporting each authoritative effect only once. Example: _“What have we decided about authentication?”_ -### `bin/decision` +### `darrow-decision` -A portable Bash facade shared by both skills. It discovers ADRs, validates +A contained Python package shared by both skills. It discovers ADRs, validates their structure and relationships, allocates collision-safe identifiers, and supports deterministic inspection and filtering. A checked-in Markdown `README.md` beside the ADRs catalogs every record's canonical Summary, status, @@ -42,9 +42,9 @@ something is a decision and where it belongs. Build and verify the derived ADR-only catalog with: ```sh -bash bin/decision catalog rebuild --repo /absolute/path/to/repository -bash bin/decision catalog check --repo /absolute/path/to/repository -bash bin/decision validate --repo /absolute/path/to/repository +uv run --quiet --frozen --no-dev --project backend darrow-decision catalog rebuild --repo /absolute/path/to/repository +uv run --quiet --frozen --no-dev --project backend darrow-decision catalog check --repo /absolute/path/to/repository +uv run --quiet --frozen --no-dev --project backend darrow-decision validate --repo /absolute/path/to/repository ``` Rebuilds are byte-deterministic and atomic. `validate` rejects a stale checked-in @@ -76,7 +76,10 @@ Capture one explicit decision or find existing decisions. Do not infer accepted ## Hosts and prerequisites -Codex and Claude Code; Bash, baseline Unix tools, and access to authoritative decision records. +Codex and Claude Code; UV, Python 3.10–3.13, Git, and access to authoritative +decision records. The CLI runs natively on Windows, Linux, and macOS. Runtime +dependencies are empty; quality tools are locked in the development group. +The plugin has no Bash runtime entrypoint or sibling-plugin dependency. ## Installation diff --git a/plugins/foundation/darrow-decisions/backend/pyproject.toml b/plugins/foundation/darrow-decisions/backend/pyproject.toml new file mode 100644 index 00000000..0d972bcd --- /dev/null +++ b/plugins/foundation/darrow-decisions/backend/pyproject.toml @@ -0,0 +1,57 @@ +[project] +name = "darrow-decisions" +version = "0.2.0" +description = "Deterministic ADR mechanics for Darrow decision skills" +requires-python = ">=3.10,<3.14" +dependencies = [] + +[project.scripts] +darrow-decision = "darrow_decisions.cli:entrypoint" + +[dependency-groups] +dev = [ + "coverage[toml]>=7.10,<8", + "hypothesis>=6.138,<7", + "mypy>=1.18,<2", + "pytest>=8.4,<9", + "ruff>=0.12,<1", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.uv] +default-groups = [] + +[tool.hatch.build.targets.wheel] +packages = ["src/darrow_decisions"] + +[tool.ruff] +target-version = "py310" +line-length = 88 +src = ["src", "tests"] + +[tool.ruff.lint] +select = ["B", "C4", "C90", "E4", "E7", "E9", "F", "I", "N", "RUF", "SIM", "UP"] +mccabe.max-complexity = 5 + +[tool.ruff.format] +docstring-code-format = true + +[tool.mypy] +python_version = "3.10" +strict = true +files = ["src", "tests"] + +[tool.pytest.ini_options] +addopts = ["--strict-config", "--strict-markers"] +testpaths = ["tests"] + +[tool.coverage.run] +branch = true +source = ["darrow_decisions"] + +[tool.coverage.report] +show_missing = true +skip_covered = false diff --git a/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/__init__.py b/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/__init__.py new file mode 100644 index 00000000..a05b26c4 --- /dev/null +++ b/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/__init__.py @@ -0,0 +1 @@ +"""Contained deterministic ADR mechanics.""" diff --git a/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/arguments.py b/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/arguments.py new file mode 100644 index 00000000..534e1c35 --- /dev/null +++ b/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/arguments.py @@ -0,0 +1,103 @@ +"""Strict command options with the existing usage and diagnostic interface.""" + +from dataclasses import dataclass, field + +from .errors import DecisionError +from .model import CONTROL, STATUSES, byte_length + +USAGE = """usage: decision inspect [--dir ] [--repo ] + decision list [--dir ] [--repo ] + [--status Proposed|Accepted|Rejected|Deprecated|Superseded] + [--search ] [--related-to ] [--limit ] + decision next-id [--dir ] [--repo ] [--title ] + decision validate [--dir <path>] [--repo <path>] + decision catalog rebuild|check [--dir <path>] [--repo <path>] + decision canonical-path --path <path> [--repo <path>] + decision check-transition --from <status> --to <status>""" + +OPTIONS = { + "inspect": ("repo", "dir"), + "validate": ("repo", "dir"), + "catalog": ("repo", "dir"), + "list": ("repo", "dir", "status", "search", "related-to", "limit"), + "next-id": ("repo", "dir", "title"), + "canonical-path": ("repo", "path"), + "check-transition": ("from", "to"), +} + + +@dataclass(frozen=True) +class Arguments: + command: str + action: str = "" + values: dict[str, str] = field(default_factory=dict) + + def get(self, name: str, default: str = "") -> str: + return self.values.get(name, default) + + +def parse_arguments(argv: list[str]) -> Arguments: + if not argv or argv[0] not in OPTIONS: + raise DecisionError(USAGE) + command, *rest = argv + action = "" + if command == "catalog": + if not rest or rest[0] not in ("rebuild", "check"): + raise DecisionError(USAGE) + action, *rest = rest + values = parse_options(command, rest) + required = {"canonical-path": ("path",), "check-transition": ("from", "to")}.get( + command, () + ) + if any(key not in values for key in required): + raise DecisionError(USAGE) + validate_values(values) + return Arguments(command, action, values) + + +def parse_options(command: str, rest: list[str]) -> dict[str, str]: + if len(rest) % 2: + raise DecisionError(USAGE) + values = {} + allowed = {"--" + name for name in OPTIONS[command]} + for flag, value in zip(rest[::2], rest[1::2], strict=True): + if flag not in allowed: + raise DecisionError(USAGE) + values[flag[2:]] = value + return values + + +def validate_values(values: dict[str, str]) -> None: + for name in ( + "repo", + "dir", + "status", + "search", + "related-to", + "title", + "path", + "from", + "to", + ): + if CONTROL.search(values.get(name, "")): + raise DecisionError(f"--{name} contains unsupported control bytes") + if byte_length(values.get("path", "")) > 1000: + raise DecisionError("--path must be at most 1000 bytes") + + +def check_transition(arguments: Arguments) -> None: + old, new = arguments.get("from"), arguments.get("to") + if not old or not new: + raise DecisionError(USAGE) + for value in (old, new): + if value not in STATUSES: + raise DecisionError(f"unsupported decision status: {value}") + allowed = { + ("Proposed", "Accepted"), + ("Proposed", "Rejected"), + ("Accepted", "Deprecated"), + ("Accepted", "Superseded"), + } + if (old, new) not in allowed: + raise DecisionError(f"unsupported decision transition: {old} -> {new}", 3) + print(f"{old} -> {new}: allowed") diff --git a/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/catalog.py b/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/catalog.py new file mode 100644 index 00000000..22d152eb --- /dev/null +++ b/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/catalog.py @@ -0,0 +1,155 @@ +"""Catalog freshness, rendering from raw bytes, and atomic replacement.""" + +import os +import tempfile +from pathlib import Path + +from .catalog_format import FORMAT, CatalogRow, checksum, render +from .catalog_parse import parse_catalog +from .errors import DecisionError +from .inventory import file_error, inventory, markdown_files +from .model import CONTROL, Record +from .paths import git, read_bytes + + +def fingerprint(root: Path, path: Path) -> str: + result = git(root, "hash-object", "--no-filters", str(path)) + value = result.stdout.decode("ascii", errors="replace").strip() + if ( + result.returncode + or not value + or any(char not in "0123456789abcdef" for char in value) + ): + raise DecisionError(f"cannot fingerprint ADR file: {path}") + return value + + +def make_rows(root: Path, records: list[Record]) -> list[CatalogRow]: + rows = [] + for record in records: + row = CatalogRow(record, fingerprint(root, record.path), 0, 0) + crc, size = checksum(row.signature()) + rows.append(CatalogRow(record, row.fingerprint, crc, size)) + return rows + + +def catalog_problem(path: Path) -> str: + if not path.exists() and not path.is_symlink(): + return "missing" + if path.is_symlink() or not path.is_file(): + return "malformed" + if not os.access(path, os.R_OK): + return "unreadable" + return "" + + +def load(path: Path) -> list[CatalogRow]: + problem = catalog_problem(path) + if problem: + raise DecisionError(f"ADR catalog is {problem}: {path}", 4) + try: + return parse_catalog(read_bytes(path, "ADR catalog is unreadable"), path.parent) + except ValueError as error: + raise DecisionError(f"ADR catalog is malformed: {path}", 4) from error + + +def committed(root: Path, path: Path) -> bool: + relative = path.relative_to(root).as_posix() + blob = git(root, "cat-file", "blob", f"HEAD:{relative}") + flag = git(root, "ls-files", "-v", "--", relative) + return ( + blob.returncode == 0 + and flag.returncode == 0 + and flag.stdout.decode("utf-8", errors="surrogateescape").rstrip("\n") + == f"H {relative}" + and blob.stdout == read_bytes(path, "ADR catalog is unreadable") + ) + + +def fresh(root: Path, directory: Path) -> tuple[list[Record], str]: + path = directory / "README.md" + problem = catalog_problem(path) + if problem: + return [], problem + if not committed(root, path): + return [], "stale" + try: + rows = load(path) + except DecisionError: + return [], "malformed" + return check_sources(root, directory, rows) + + +def check_sources( + root: Path, directory: Path, rows: list[CatalogRow] +) -> tuple[list[Record], str]: + files = [path for path in markdown_files(directory) if path.name != "README.md"] + if any(CONTROL.search(str(path)) or file_error(path) for path in files): + return [], "stale" + if set(files) != {row.record.path for row in rows}: + return [], "stale" + problem = row_problem(root, rows) + return sorted((row.record for row in rows), key=Record.sort_key), problem + + +def row_problem(root: Path, rows: list[CatalogRow]) -> str: + for row in rows: + if not row.valid_checksum(): + return "malformed" + if fingerprint(root, row.record.path) != row.fingerprint: + return "stale" + return "" + + +def verify(root: Path, directory: Path, records: list[Record]) -> None: + path = directory / "README.md" + load(path) + expected = render(make_rows(root, records)) + if expected != read_bytes(path, "ADR catalog is unreadable"): + raise DecisionError(f"ADR catalog is stale: {path}", 4) + + +def atomic_write(path: Path, data: bytes) -> None: + temporary: Path | None = None + try: + with tempfile.NamedTemporaryFile( + dir=path.parent, prefix=path.name + ".tmp.", delete=False + ) as stream: + temporary = Path(stream.name) + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + except OSError as error: + action = ( + f"cannot replace ADR catalog: {path}" + if temporary + else f"cannot create an atomic ADR catalog in: {path.parent}" + ) + raise DecisionError(action) from error + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + + +def rebuild(root: Path, directory: Path) -> list[Record]: + path = directory / "README.md" + problem = catalog_problem(path) + if problem != "missing": + require_replaceable(path, problem) + records = inventory(directory) + atomic_write(path, render(make_rows(root, records))) + return records + + +def require_replaceable(path: Path, problem: str) -> None: + if problem: + adjective = "a regular file" if problem == "malformed" else "readable" + raise DecisionError(f"ADR catalog is not {adjective}: {path}") + lines = read_bytes(path, "ADR catalog is not readable").split(b"\n") + markers = (FORMAT.encode(), b"<!-- darrow-adr-catalog-v1 -->") + if not any(marker in lines for marker in markers): + raise DecisionError( + f"refusing to replace a README without the derived ADR catalog marker: {path}", + 4, + ) diff --git a/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/catalog_format.py b/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/catalog_format.py new file mode 100644 index 00000000..0c0654d0 --- /dev/null +++ b/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/catalog_format.py @@ -0,0 +1,170 @@ +"""Byte-stable v2 Markdown catalog encoding, with its public POSIX checksum.""" + +import re +from dataclasses import dataclass +from pathlib import Path + +from .model import STATUSES, Record + +FORMAT = "<!-- darrow-adr-catalog-v2 -->" +INTRO = ( + "# ADR Catalog\n\n" + "This checked-in catalog is derived, non-authoritative metadata. ADR files remain canonical.\n" + "Rebuild it with `decision catalog rebuild` and verify it with `decision catalog check`.\n\n" + f"{FORMAT}\n" +) +TABLE = ( + "<!-- prettier-ignore -->\n" + "| Decision | Status | Date | Summary | Relationships |\n" + "| --- | --- | --- | --- | --- |\n" +) +TARGET_ESCAPES = {char: f"%{ord(char):02X}" for char in "%\\ ()#?|<>"} + + +def target(value: str) -> str: + return "".join(TARGET_ESCAPES.get(char, char) for char in value) + + +def untarget(value: str) -> str: + inverse = {escaped: char for char, escaped in TARGET_ESCAPES.items()} + return re.sub(r"%[0-9A-F]{2}", lambda match: inverse.get(match[0], match[0]), value) + + +def cell(value: str, *, label: bool = False) -> str: + value = value.replace("&", "&").replace("<", "<").replace(">", ">") + escaped = "\\|[]" if label else "\\|" + return "".join("\\" + char if char in escaped else char for char in value) + + +def unhtml(value: str) -> str: + return value.replace("<", "<").replace(">", ">").replace("&", "&") + + +def relationships(record: Record) -> str: + pairs = ( + ("Supersedes", record.supersedes), + ("Superseded by", record.superseded_by), + ("Revisit when", record.revisit), + ) + return "; ".join(f"{key}: {value}" for key, value in pairs if value) or "None" + + +def crc_byte(crc: int, value: int) -> int: + crc ^= value << 24 + for _ in range(8): + crc = (crc << 1) ^ (0x04C11DB7 if crc & 0x80000000 else 0) + return crc & 0xFFFFFFFF + + +def checksum(payload: bytes) -> tuple[int, int]: + crc = 0 + for value in payload: + crc = crc_byte(crc, value) + length = len(payload) + while length: + crc = crc_byte(crc, length & 255) + length >>= 8 + return (~crc & 0xFFFFFFFF, len(payload)) + + +@dataclass(frozen=True) +class CatalogRow: + record: Record + fingerprint: str + crc: int + size: int + + def signature(self) -> bytes: + name = self.record.path.name + return ( + "\t".join((self.fingerprint, name, target(name), *self.record.values())) + + "\n" + ).encode("utf-8", errors="surrogateescape") + + def valid_checksum(self) -> bool: + return checksum(self.signature()) == (self.crc, self.size) + + +def row_text(row: CatalogRow) -> str: + record = row.record + return ( + f"| [{cell(record.identifier + ': ' + record.title, label=True)}]" + f"({target(record.path.name)}) | {record.status} | {record.date} | " + f"{cell(record.summary)} | {cell(relationships(record))} |\n" + ) + + +def render(rows: list[CatalogRow]) -> bytes: + parts = [INTRO] + for status in STATUSES: + group = [row for row in rows if row.record.status == status] + parts.extend((f"\n## {status}\n\n", TABLE)) + parts.extend(row_text(row) for row in group) + if group: + parts.append("\n") + parts.extend( + f"<!-- darrow-source: {row.fingerprint} {row.crc} {row.size} " + f"{target(row.record.path.name)} -->\n" + for row in group + ) + return "".join(parts).encode("utf-8", errors="surrogateescape") + + +def split_cells(line: str) -> list[str]: + # An escaped backslash consumes its successor before a pipe can delimit. + if not line.startswith("| ") or not line.endswith(" |"): + raise ValueError("invalid table row") + pieces = re.findall(r"((?:\\.|[^\\|])*)\|", line[1:]) + return [re.sub(r"\\(.)", r"\1", value).strip(" \t") for value in pieces] + + +def parse_relationships(value: str) -> tuple[str, str, str]: + if value == "None": + return "", "", "" + result = [] + rest = value + for prefix in ("Supersedes: ", "Superseded by: "): + item = "" + if rest.startswith(prefix): + item, _, rest = rest[len(prefix) :].partition("; ") + result.append(item) + revisit = ( + rest.removeprefix("Revisit when: ") if rest.startswith("Revisit when: ") else "" + ) + if (rest and not revisit) or not any((*result, revisit)): + raise ValueError("invalid relationships") + return result[0], result[1], revisit + + +def parse_record(line: str, status: str, directory: Path) -> Record: + cells = split_cells(line) + if len(cells) != 5: + raise ValueError("invalid table cells") + label, state, date, summary, relations = cells + match = re.fullmatch(r"\[(ADR-[0-9]{4,}): (.+)\]\((.*)\)", label) + if not match or state != status or not summary: + raise ValueError("invalid catalog record") + name = untarget(match[3]) + if Path(name).name != name or not re.fullmatch(r"ADR-[0-9]+.*\.md", name): + raise ValueError("invalid catalog filename") + if target(name) != match[3]: + raise ValueError("invalid catalog link") + return Record( + match[1], + unhtml(match[2]), + state, + date, + unhtml(summary), + *parse_relationships(unhtml(relations)), + directory / name, + ) + + +def parse_source(line: str, record: Record) -> CatalogRow: + match = re.fullmatch( + r"<!-- darrow-source: ([0-9a-f]{40}|[0-9a-f]{64}) ([0-9]+) ([0-9]+) (.+) -->", + line, + ) + if not match or untarget(match[4]) != record.path.name: + raise ValueError("invalid source marker") + return CatalogRow(record, match[1], int(match[2]), int(match[3])) diff --git a/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/catalog_parse.py b/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/catalog_parse.py new file mode 100644 index 00000000..3e48ba94 --- /dev/null +++ b/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/catalog_parse.py @@ -0,0 +1,79 @@ +"""Parse catalog structure independently of source body parsing.""" + +from collections import Counter +from dataclasses import dataclass, field +from pathlib import Path + +from .catalog_format import INTRO, TABLE, CatalogRow, parse_record, parse_source +from .model import STATUSES, Record + +CONSTANTS = Counter(line for line in (INTRO + TABLE * 5).splitlines() if line.strip()) + + +@dataclass +class CatalogParser: + directory: Path + counts: Counter[str] = field(default_factory=Counter) + sections: list[str] = field(default_factory=list) + records: list[Record] = field(default_factory=list) + rows: list[CatalogRow] = field(default_factory=list) + current: str = "" + tables: Counter[tuple[str, str]] = field(default_factory=Counter) + + def consume(self, line: str) -> None: + if line in CONSTANTS: + self.constant(line) + elif line.startswith("## "): + self.current = line[3:] + self.sections.append(self.current) + elif line.startswith("| "): + self.record(line) + elif line.startswith("<!-- darrow-source: ") and len(self.rows) < len( + self.records + ): + self.rows.append(parse_source(line, self.records[len(self.rows)])) + else: + raise ValueError("unsupported catalog line") + + def constant(self, line: str) -> None: + self.counts[line] += 1 + if line.startswith("|"): + key = self.current, line + self.tables[key] += 1 + if self.current not in STATUSES or self.tables[key] != 1: + raise ValueError("invalid table header") + + def record(self, line: str) -> None: + if not all( + self.tables[self.current, header] == 1 for header in TABLE.splitlines()[1:] + ): + raise ValueError("missing table header") + self.records.append(parse_record(line, self.current, self.directory)) + + +def parse_catalog(data: bytes, directory: Path) -> list[CatalogRow]: + parser = CatalogParser(directory) + for line in data.decode("utf-8", errors="surrogateescape").split("\n"): + if line.strip(" \t"): + parser.consume(line) + validate_counts( + parser.counts, CONSTANTS, parser.sections, parser.records, parser.rows + ) + return parser.rows + + +def validate_counts( + counts: Counter[str], + constants: Counter[str], + sections: list[str], + records: list[Record], + rows: list[CatalogRow], +) -> None: + if counts != constants or sorted(sections) != sorted(STATUSES): + raise ValueError("invalid catalog structure") + if len(records) != len(rows): + raise ValueError("missing catalog sources") + if len({record.identifier for record in records}) != len(records): + raise ValueError("duplicate catalog identifier") + if len({record.path.name for record in records}) != len(records): + raise ValueError("duplicate catalog filename") diff --git a/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/cli.py b/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/cli.py new file mode 100644 index 00000000..a3dc7c01 --- /dev/null +++ b/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/cli.py @@ -0,0 +1,93 @@ +"""Public command dispatch; contextual decision authority stays in the skills.""" + +import io +import sys +from pathlib import Path + +from . import catalog, queries +from .arguments import USAGE, Arguments, check_transition, parse_arguments +from .errors import DecisionError +from .inventory import inventory +from .paths import canonical, resolve_root, select_directory + + +def require_directory(directory: Path | None) -> Path: + if directory is None: + raise DecisionError( + "no ADR directory detected; create or select one with --dir" + ) + return directory + + +def validate(root: Path, directory: Path | None) -> None: + records = inventory(directory) + if directory is None: + print("valid: 0 ADR(s); no ADR directory detected") + return + path = directory / "README.md" + if records or path.exists() or path.is_symlink(): + catalog.verify(root, directory, records) + print(f"valid: {len(records)} ADR(s) in {directory}") + + +def run_catalog(root: Path, directory: Path, action: str) -> None: + if action == "rebuild": + records = catalog.rebuild(root, directory) + prefix = "catalog" + else: + catalog.load(directory / "README.md") + records = inventory(directory) + catalog.verify(root, directory, records) + prefix = "fresh" + print(f"{prefix}: {directory / 'README.md'}\nrecords: {len(records)}") + + +def run(arguments: Arguments) -> None: + if arguments.command == "check-transition": + check_transition(arguments) + return + root = resolve_root(arguments.get("repo", ".")) + if arguments.command == "canonical-path": + value = arguments.get("path") + if not value: + raise DecisionError(USAGE) + print(f"path: {canonical(root, value)}") + return + directory = select_directory(root, arguments.get("dir")) + dispatch(arguments, root, directory) + + +def dispatch(arguments: Arguments, root: Path, directory: Path | None) -> None: + command = arguments.command + if command == "inspect": + queries.inspect(root, directory) + elif command == "list": + queries.listing(root, directory, arguments) + elif command == "validate": + validate(root, directory) + elif command == "next-id": + queries.next_id(require_directory(directory), arguments.get("title")) + else: + run_catalog(root, require_directory(directory), arguments.action) + + +def main(argv: list[str] | None = None) -> int: + try: + run(parse_arguments(sys.argv[1:] if argv is None else argv)) + except DecisionError as error: + message = str(error) + prefix = "" if message.startswith(("usage:", "error:")) else "error: " + print(prefix + message, file=sys.stderr) + return error.code + except OSError as error: + # Last-resort process boundary for filesystem races after validation. + print(f"error: decision filesystem operation failed: {error}", file=sys.stderr) + return 2 + return 0 + + +def entrypoint() -> None: + for stream in (sys.stdout, sys.stderr): + if isinstance(stream, io.TextIOWrapper): + stream.reconfigure(encoding="utf-8", newline="\n") + raise SystemExit(main()) diff --git a/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/errors.py b/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/errors.py new file mode 100644 index 00000000..e79f1fb7 --- /dev/null +++ b/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/errors.py @@ -0,0 +1,28 @@ +"""Stable CLI failures and bounded validation diagnostics.""" + +from dataclasses import dataclass, field + + +class DecisionError(Exception): + def __init__(self, message: str, code: int = 2) -> None: + super().__init__(message) + self.code = code + + +@dataclass +class Diagnostics: + messages: list[str] = field(default_factory=list) + count: int = 0 + + def add(self, message: str) -> None: + self.count += 1 + if len(self.messages) < 40: + self.messages.append(f"error: {message}") + + def require_valid(self) -> None: + if not self.count: + return + lines = list(self.messages) + if self.count > 40: + lines.append(f"error: {self.count - 40} additional diagnostics omitted") + raise DecisionError("\n".join(lines), 4) diff --git a/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/inventory.py b/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/inventory.py new file mode 100644 index 00000000..dbf4d7da --- /dev/null +++ b/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/inventory.py @@ -0,0 +1,72 @@ +"""Enumerate and validate canonical ADRs; catalogs never hide invalid inputs.""" + +import os +from pathlib import Path + +from .errors import DecisionError, Diagnostics +from .model import CONTROL, Record +from .parser import parse +from .paths import read_text +from .relations import validate_relations +from .validation import validate_record + + +def markdown_files(directory: Path) -> list[Path]: + try: + return sorted( + (path for path in directory.iterdir() if path.name.endswith(".md")), + key=lambda path: os.fsencode(path.name), + ) + except OSError as error: + raise DecisionError( + f"{directory}: cannot enumerate Markdown entries", 4 + ) from error + + +def file_error(path: Path) -> str: + if path.is_symlink(): + return "ADR symlinks are not allowed" + if not path.is_file(): + return "ADR path is occupied by an unsupported file type" + if not os.access(path, os.R_OK): + return "Markdown file is not readable" + return "" + + +def load_record(path: Path, errors: Diagnostics) -> Record | None: + problem = file_error(path) + if problem: + errors.add(f"{path}: {problem}") + return None + try: + parsed = parse(read_text(path, "cannot parse ADR file")) + except DecisionError: + errors.add(f"{path}: cannot parse ADR file") + return None + record = parsed.record(path) + validate_record(parsed, record, errors) + return record + + +def inventory(directory: Path | None) -> list[Record]: + if directory is None: + return [] + errors = Diagnostics() + files = markdown_files(directory) + unsafe = sum(bool(CONTROL.search(str(path))) for path in files) + if unsafe: + errors.add( + f"{directory}: {unsafe} ADR filename(s) contain unsupported control bytes" + ) + safe = [ + path + for path in files + if not CONTROL.search(str(path)) and path.name != "README.md" + ] + records = [ + record for path in safe if (record := load_record(path, errors)) is not None + ] + records.sort(key=Record.sort_key) + validate_relations(records, errors) + errors.require_valid() + return records diff --git a/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/model.py b/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/model.py new file mode 100644 index 00000000..312075ef --- /dev/null +++ b/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/model.py @@ -0,0 +1,50 @@ +"""Decision records shared by source validation and derived catalogs.""" + +import re +from dataclasses import dataclass +from pathlib import Path + +STATUSES = ("Proposed", "Accepted", "Rejected", "Deprecated", "Superseded") +EFFECTIVE = frozenset(("Accepted", "Deprecated", "Superseded")) +CONTROL = re.compile(r"[\x00-\x1f\x7f]") +IDENTIFIER = re.compile(r"ADR-[0-9]{4,18}") +FIELDS = ("Status", "Date", "Summary", "Supersedes", "Superseded by", "Revisit when") +SECTIONS = ("Context", "Decision", "Consequences") + + +def byte_length(value: str) -> int: + return len(value.encode("utf-8", errors="surrogateescape")) + + +def relation_ids(value: str) -> list[str]: + return [part.strip(" \t") for part in value.split(",")] if value else [] + + +@dataclass(frozen=True) +class Record: + identifier: str + title: str + status: str + date: str + summary: str + supersedes: str + superseded_by: str + revisit: str + path: Path + + def values(self) -> tuple[str, ...]: + return ( + self.identifier, + self.title, + self.status, + self.date, + self.summary, + self.supersedes, + self.superseded_by, + self.revisit, + ) + + def sort_key(self) -> bytes: + return "\x1c".join((*self.values(), str(self.path))).encode( + "utf-8", errors="surrogateescape" + ) diff --git a/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/parser.py b/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/parser.py new file mode 100644 index 00000000..393fbf79 --- /dev/null +++ b/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/parser.py @@ -0,0 +1,107 @@ +"""A small Markdown state machine for the existing ADR metadata dialect.""" + +import re +from collections import Counter +from dataclasses import dataclass, field +from pathlib import Path + +from .model import CONTROL, FIELDS, SECTIONS, Record + +HEADING = re.compile(r"^(#{1,6})[ \t]+(.*)$") +FENCE = re.compile(r"^(`{3,}|~{3,})(.*)$") + + +@dataclass +class Parsed: + values: dict[str, str] = field(default_factory=dict) + counts: Counter[str] = field(default_factory=Counter) + content: set[str] = field(default_factory=set) + section: str = "" + metadata: bool = True + fence: str = "" + + def fence_line(self, line: str, scan: str) -> bool: + match = FENCE.match(scan) + if match: + self.toggle_fence(match[1], match[2], line) + return True + if self.fence: + self.mark_content(line) + return True + return False + + def toggle_fence(self, marker: str, tail: str, line: str) -> None: + if not self.fence: + self.fence = marker + elif ( + marker[0] == self.fence[0] + and len(marker) >= len(self.fence) + and not tail.strip(" \t") + ): + self.fence = "" + else: + self.mark_content(line) + + def mark_content(self, line: str) -> None: + if self.section and line.strip(" \t"): + self.content.add(self.section) + + def heading(self, level: int, text: str) -> None: + text = re.sub(r"[ \t]+#+[ \t]*$", "", text.strip(" \t")) + if level == 1 and text.startswith("ADR-"): + self.counts["heading"] += 1 + identifier, _, title = text.partition(":") + self.values.update( + identifier=identifier.strip(" \t"), title=title.strip(" \t") + ) + if level <= 2: + self.section = "" + if level == 2: + self.start_section(text) + + def start_section(self, text: str) -> None: + self.metadata = False + if text in SECTIONS: + self.section = text + self.counts[text] += 1 + + def ordinary_line(self, line: str) -> None: + key, separator, value = line.partition(":") + if self.metadata and separator and key in FIELDS: + self.counts[key] += 1 + self.values[key] = value.strip(" \t") + else: + self.mark_content(line) + + def record(self, path: Path) -> Record: + keys = ("identifier", "title", *FIELDS) + identifier, title, status, date, summary, supersedes, replaced, revisit = ( + CONTROL.sub(" ", self.values.get(key, "")) for key in keys + ) + return Record( + identifier, + title, + status, + date, + summary, + supersedes, + replaced, + revisit, + path, + ) + + +def parse(text: str) -> Parsed: + result = Parsed() + # Split only LF: other control bytes belong to metadata, not line boundaries. + lines = text.removeprefix("\ufeff").split("\n") + for line in lines: + scan = re.sub(r"^ {0,3}", "", line) + if result.fence_line(line, scan): + continue + match = HEADING.match(scan) + if match: + result.heading(len(match[1]), match[2]) + else: + result.ordinary_line(line) + return result diff --git a/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/paths.py b/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/paths.py new file mode 100644 index 00000000..c741c144 --- /dev/null +++ b/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/paths.py @@ -0,0 +1,108 @@ +"""Native filesystem boundaries and canonical primary-worktree resolution.""" + +import os +import subprocess +from pathlib import Path + +from .errors import DecisionError +from .model import CONTROL + +ADR_DIRECTORIES = ( + "docs/decisions", + "docs/adrs", + "docs/adr", + "architecture/decisions", + "adrs", + "adr", +) + + +def git(root: Path, *args: str) -> subprocess.CompletedProcess[bytes]: + try: + return subprocess.run( + ["git", "-C", str(root), *args], capture_output=True, check=False + ) + except OSError as error: + raise DecisionError(f"cannot run Git: {error.strerror}") from error + + +def resolve_root(value: str) -> Path: + target = Path(value) + if not value or not target.is_dir() or not os.access(target, os.R_OK): + raise DecisionError(f"repository path is not a readable directory: {value}") + result = git(target, "worktree", "list", "--porcelain") + if result.returncode: + raise DecisionError(f"not a Git repository: {value}") + first = result.stdout.decode("utf-8", errors="surrogateescape").split("\n", 1)[0] + if not first.startswith("worktree ") or not first[9:]: + raise DecisionError("cannot resolve repository root from git worktree metadata") + if CONTROL.search(first[9:]): + raise DecisionError("repository root contains unsupported control bytes") + return Path(first[9:]).resolve() + + +def has_symlink(path: Path) -> bool: + return any(part.is_symlink() for part in (path, *path.parents)) + + +def contained(path: Path, root: Path, kind: str) -> Path: + resolved = path.resolve() + if not resolved.is_relative_to(root): + raise DecisionError(f"{kind} escapes the repository root: {resolved}") + return resolved + + +def canonical(root: Path, value: str, *, directory: bool = False) -> Path: + candidate = root / value + kind = "ADR directory" if directory else "canonical record" + if has_symlink(candidate): + raise DecisionError(f"{kind} symlinks are not allowed: {candidate}") + check_type(candidate, directory) + mode = os.R_OK | (os.X_OK if directory else 0) + if not os.access(candidate, mode): + raise DecisionError(f"{kind} is not readable: {candidate}") + return contained(candidate, root, kind) + + +def check_type(path: Path, directory: bool) -> None: + if directory: + if not path.is_dir(): + raise DecisionError(f"ADR directory does not exist: {path}") + elif not path.is_file(): + raise DecisionError(f"canonical record is not a regular file: {path}") + + +def select_directory(root: Path, value: str = "") -> Path | None: + if value: + return canonical(root, value, directory=True) + unique = list(dict.fromkeys(discover_directories(root))) + if len(unique) > 1: + raise DecisionError( + "multiple ADR directories found; pass --dir explicitly:\n" + + "\n".join(f" - {path}" for path in unique), + 3, + ) + return next(iter(unique), None) + + +def discover_directories(root: Path) -> list[Path]: + candidates = [] + for name in ADR_DIRECTORIES: + path = root / name + if not path.exists() and not path.is_symlink(): + continue + if not path.is_dir(): + raise DecisionError(f"ADR directory candidate is not a directory: {path}") + candidates.append(canonical(root, name, directory=True)) + return candidates + + +def read_bytes(path: Path, diagnostic: str) -> bytes: + try: + return path.read_bytes() + except OSError as error: + raise DecisionError(f"{diagnostic}: {path}") from error + + +def read_text(path: Path, diagnostic: str) -> str: + return read_bytes(path, diagnostic).decode("utf-8", errors="surrogateescape") diff --git a/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/queries.py b/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/queries.py new file mode 100644 index 00000000..19577156 --- /dev/null +++ b/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/queries.py @@ -0,0 +1,160 @@ +"""Read-only inspection, literal filtering, and next-ID allocation.""" + +import os +import re +import sys +from pathlib import Path + +from . import catalog +from .arguments import Arguments +from .errors import DecisionError +from .inventory import inventory +from .model import STATUSES, Record, byte_length, relation_ids +from .paths import read_bytes + + +def surfaces(root: Path, names: tuple[str, ...], kind: str) -> None: + print(f"{kind}-surfaces:") + found = False + for name in names: + path = root / name + if not path.exists() and not (kind == "policy" and path.is_symlink()): + continue + if not os.access(path, os.R_OK): + raise DecisionError(f"canonical {kind} surface is not readable: {path}") + suffix = ( + surface_count(path) if kind == "specification" and path.is_dir() else "" + ) + print(f" - {path}{suffix}") + found = True + if not found: + print(" - (none detected)") + + +def surface_count(path: Path) -> str: + try: + count = sum( + entry.is_file() and not entry.is_symlink() and entry.name.endswith(".md") + for entry in path.iterdir() + ) + except OSError as error: + raise DecisionError( + f"cannot enumerate canonical specification surface: {path}" + ) from error + return f" ({count} Markdown files)" + + +def inspect(root: Path, directory: Path | None) -> None: + records = inventory(directory) + print(f"root: {root}") + print(f"adr-directory: {directory or '(none detected)'}") + print(f"adr-records: {len(records)}") + surfaces(root, ("docs/specs", "specs"), "specification") + surfaces( + root, ("AGENTS.md", "CLAUDE.md", "CONTRIBUTING.md", "GOVERNANCE.md"), "policy" + ) + + +def list_limit(arguments: Arguments) -> int: + status, related = arguments.get("status"), arguments.get("related-to") + if status and status not in STATUSES: + raise DecisionError(f"unsupported decision status: {status}") + if related and not re.fullmatch(r"ADR-[0-9]{4,}", related): + raise DecisionError(f"invalid related decision identifier: {related}") + if byte_length(related) > 22: + raise DecisionError("related decision identifier exceeds the 18-digit limit") + return validate_search_limit(arguments) + + +def validate_search_limit(arguments: Arguments) -> int: + if byte_length(arguments.get("search")) > 200: + raise DecisionError("--search must be at most 200 bytes") + value = arguments.get("limit", "50") + # Bound before integer conversion, including arbitrarily long leading zeros. + normalized = value.lstrip("0") + if not re.fullmatch(r"[0-9]+", value) or not normalized or len(normalized) > 3: + raise DecisionError("--limit must be an integer from 1 to 200") + limit = int(normalized) + if limit > 200: + raise DecisionError("--limit must be an integer from 1 to 200") + return limit + + +def list_inventory(root: Path, directory: Path | None, search: str) -> list[Record]: + if directory is None: + return [] + records, problem = catalog.fresh(root, directory) + if problem: + print( + f"warning: ADR catalog is {problem}; using full scan: {directory / 'README.md'}", + file=sys.stderr, + ) + if problem or search: + return inventory(directory) + return records + + +def matches(record: Record, arguments: Arguments) -> bool: + status, related, search = ( + arguments.get(key) for key in ("status", "related-to", "search") + ) + if status and record.status != status: + return False + if related and related not in ( + *relation_ids(record.supersedes), + *relation_ids(record.superseded_by), + ): + return False + if search: + body = read_bytes(record.path, "cannot search ADR file") + return search.encode("utf-8", errors="surrogateescape").lower() in body.lower() + return True + + +def listing(root: Path, directory: Path | None, arguments: Arguments) -> None: + limit = list_limit(arguments) + records = list_inventory(root, directory, arguments.get("search")) + matched = [record for record in records if matches(record, arguments)] + for record in matched[:limit]: + suffix = "".join( + f"; {key}: {value}" + for key, value in ( + ("supersedes", record.supersedes), + ("superseded by", record.superseded_by), + ) + if value + ) + print( + f"{record.identifier} {record.status} architecture — {record.title} — {record.path}{suffix}" + ) + print( + f"total: {len(matched)} (status={arguments.get('status') or 'all'}, " + f"search={arguments.get('search') or '(none)'}, related-to={arguments.get('related-to') or '(none)'})" + ) + if len(matched) > limit: + print(f"note: showing first {limit} of {len(matched)} matching ADRs") + + +def next_id(directory: Path, title: str) -> None: + records = inventory(directory) + highest = max((int(record.identifier[4:]) for record in records), default=0) + if highest >= 999999999999999999: + raise DecisionError("ADR identifier space is exhausted", 4) + identifier = f"ADR-{highest + 1:04d}" + print(f"id: {identifier}") + if title: + path = directory / f"{identifier}-{slugify(title)}.md" + if path.exists() or path.is_symlink(): + raise DecisionError(f"next ADR path is already occupied: {path}", 4) + print(f"path: {path}") + + +def slugify(value: str) -> str: + if byte_length(value) > 200: + raise DecisionError("--title must be at most 200 bytes") + slug = re.sub( + r"[^a-z0-9]+", "-", value.encode("utf-8").lower().decode("utf-8") + ).strip("-") + if not slug: + raise DecisionError("--title must contain an ASCII letter or digit") + return slug diff --git a/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/relations.py b/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/relations.py new file mode 100644 index 00000000..7c7464af --- /dev/null +++ b/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/relations.py @@ -0,0 +1,124 @@ +"""Validate reciprocal relationships and detect cycles without recursion.""" + +from collections.abc import Iterator + +from .errors import Diagnostics +from .model import EFFECTIVE, IDENTIFIER, Record, relation_ids + + +def lifecycle_errors(record: Record) -> list[str]: + result = [] + if record.supersedes and record.status not in EFFECTIVE: + result.append( + "Supersedes is allowed only on an Accepted, Deprecated, or Superseded replacement" + ) + if record.superseded_by and record.status != "Superseded": + result.append("Superseded by requires status Superseded") + if not record.superseded_by and record.status == "Superseded": + result.append("status Superseded requires Superseded by") + return result + + +def target_error( + target: str, source: str, kind: str, seen: set[str], records: dict[str, Record] +) -> str: + if not IDENTIFIER.fullmatch(target): + return f"invalid {kind} identifier" + if target in seen: + return f"duplicate {kind} target: {target}" + seen.add(target) + if target == source: + return f"an ADR cannot relate to itself through {kind}" + if target not in records: + return f"{kind} target does not exist: {target}" + return "" + + +def reciprocal_errors(record: Record, target: Record, kind: str) -> list[str]: + result = [] + if kind == "Supersedes": + if target.status != "Superseded": + result.append(f"{target.identifier} must have status Superseded") + inverse = target.superseded_by + inverse_name = "Superseded by" + else: + if target.status not in EFFECTIVE: + result.append( + f"replacement {target.identifier} must have status Accepted, Deprecated, or Superseded" + ) + inverse = target.supersedes + inverse_name = "Supersedes" + if record.identifier not in relation_ids(inverse): + result.append( + f"{target.identifier} does not reciprocate {inverse_name} {record.identifier}" + ) + return result + + +def validate_targets( + record: Record, kind: str, records: dict[str, Record], errors: Diagnostics +) -> list[str]: + value = record.supersedes if kind == "Supersedes" else record.superseded_by + targets = relation_ids(value) + if len(targets) > 50: + errors.add(f"{record.path}: {kind} has more than 50 targets") + seen: set[str] = set() + edges = [] + for target in targets: + error = target_error(target, record.identifier, kind, seen, records) + if error: + errors.add(f"{record.path}: {error}") + continue + edges.append(target) + for problem in reciprocal_errors(record, records[target], kind): + errors.add(f"{record.path}: {problem}") + return edges + + +def visit( + start: str, edges: dict[str, list[str]], state: dict[str, int] +) -> tuple[str, str] | None: + state[start] = 1 + stack: list[tuple[str, Iterator[str]]] = [(start, iter(edges[start]))] + while stack: + source, children = stack[-1] + target = next(children, None) + if target is None: + state[source] = 2 + stack.pop() + elif state.get(target) == 1: + return source, target + elif not state.get(target): + state[target] = 1 + stack.append((target, iter(edges[target]))) + return None + + +def validate_relations(inventory: list[Record], errors: Diagnostics) -> None: + records: dict[str, Record] = {} + for record in inventory: + if not record.identifier: + continue + if record.identifier in records: + errors.add(f"duplicate ADR identifier: {record.identifier}") + else: + records[record.identifier] = record + validate_graph(records, errors) + + +def validate_graph(records: dict[str, Record], errors: Diagnostics) -> None: + edges = {} + for identifier, record in records.items(): + for problem in lifecycle_errors(record): + errors.add(f"{record.path}: {problem}") + edges[identifier] = validate_targets(record, "Supersedes", records, errors) + validate_targets(record, "Superseded by", records, errors) + state: dict[str, int] = {} + for identifier in records: + cycle = None if state.get(identifier) else visit(identifier, edges, state) + if cycle: + source, target = cycle + errors.add( + f"{records[source].path}: supersession relationship cycle detected at edge {source} -> {target}" + ) + break diff --git a/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/validation.py b/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/validation.py new file mode 100644 index 00000000..ed86f63c --- /dev/null +++ b/plugins/foundation/darrow-decisions/backend/src/darrow_decisions/validation.py @@ -0,0 +1,116 @@ +"""Ordered structural diagnostics for parsed ADR sources.""" + +import re +from datetime import date + +from .errors import Diagnostics +from .model import IDENTIFIER, SECTIONS, STATUSES, Record, byte_length +from .parser import Parsed + + +def valid_date(value: str) -> bool: + if not re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", value): + return False + try: + date.fromisoformat(value) + except ValueError: + return False + return True + + +def identifier_errors(record: Record) -> list[str]: + value = record.identifier + if not value: + return ["ADR heading has no identifier"] + if byte_length(value) > 22: + return ["ADR identifier exceeds the 18-digit limit"] + if not IDENTIFIER.fullmatch(value): + return [f"unsupported ADR identifier: {value}"] + return [] + + +def required_field( + parsed: Parsed, key: str, value: str, limit: int, size_name: str +) -> list[str]: + count = parsed.counts[key] + if count != 1: + return [f"expected exactly one {key} field; found {count}"] + if key == "Summary" and not value: + return ["Summary field is blank"] + if byte_length(value) > limit: + return [f"{size_name} exceeds {limit} bytes"] + return field_value_errors(key, value) + + +def field_value_errors(key: str, value: str) -> list[str]: + if key == "Status" and value not in STATUSES: + return [f"unsupported decision status: {value}"] + if key == "Date" and not valid_date(value): + return [f"Date is not a valid ISO calendar date: {value}"] + return [] + + +def optional_fields(parsed: Parsed, record: Record) -> list[str]: + fields = ( + ("Supersedes", record.supersedes, 1000), + ("Superseded by", record.superseded_by, 1000), + ("Revisit when", record.revisit, 500), + ) + result = [ + f"duplicate {key} fields" for key, _, _ in fields if parsed.counts[key] > 1 + ] + result.extend( + f"{key} field is blank" + for key, value, _ in fields + if parsed.counts[key] == 1 and not value + ) + result.extend( + f"{key} exceeds {limit} bytes" + for key, value, limit in fields + if byte_length(value) > limit + ) + return result + + +def heading_errors(parsed: Parsed, record: Record) -> list[str]: + result = [] + file_match = re.match(r"^(ADR-[0-9]+)(?:-|\.md$)", record.path.name) + if not file_match: + result.append("filename does not carry its ADR identifier") + count = parsed.counts["heading"] + if count != 1: + result.append(f"expected exactly one level-1 ADR heading; found {count}") + result.extend(identifier_errors(record)) + if file_match and record.identifier and file_match[1] != record.identifier: + result.append( + f"filename identifier {file_match[1]} disagrees with heading identifier {record.identifier}" + ) + return result + + +def title_errors(value: str) -> list[str]: + if not value: + return ["ADR title is empty"] + if byte_length(value) > 240: + return ["ADR title exceeds 240 bytes"] + return [] + + +def validate_record(parsed: Parsed, record: Record, errors: Diagnostics) -> None: + problems = heading_errors(parsed, record) + title_errors(record.title) + for key, value, limit, size_name in ( + ("Status", record.status, 32, "decision status"), + ("Date", record.date, 32, "Date"), + ("Summary", record.summary, 500, "Summary"), + ): + problems.extend(required_field(parsed, key, value, limit, size_name)) + problems.extend(optional_fields(parsed, record)) + if parsed.fence: + problems.append("Markdown fence is not closed") + problems.extend( + f"{key} section must occur once and contain content" + for key in SECTIONS + if parsed.counts[key] != 1 or key not in parsed.content + ) + for problem in problems: + errors.add(f"{record.path}: {problem}") diff --git a/plugins/foundation/darrow-decisions/backend/tests/conftest.py b/plugins/foundation/darrow-decisions/backend/tests/conftest.py new file mode 100644 index 00000000..226af527 --- /dev/null +++ b/plugins/foundation/darrow-decisions/backend/tests/conftest.py @@ -0,0 +1,60 @@ +"""Real temporary repositories; mutations are confined to each fixture.""" + +import subprocess +from dataclasses import dataclass +from pathlib import Path + +import pytest + + +def adr( + identifier: str = "ADR-0001", + title: str = "Use SQLite", + status: str = "Accepted", + metadata: str = "", + body: str = "The choice is explicit.", +) -> str: + return ( + f"# {identifier}: {title}\n\nStatus: {status}\nDate: 2026-07-19\n" + f"Summary: {title}.\n{metadata}\n## Context\n\nContext.\n" + f"\n## Decision\n\n{body}\n\n## Consequences\n\nConsequences.\n" + ) + + +@dataclass +class Repository: + root: Path + + @property + def directory(self) -> Path: + return self.root / "docs/decisions" + + def git(self, *args: str) -> str: + return subprocess.run( + ["git", "-C", str(self.root), *args], + check=True, + capture_output=True, + encoding="utf-8", + ).stdout + + def write(self, identifier: str = "ADR-0001", **kwargs: str) -> Path: + self.directory.mkdir(parents=True, exist_ok=True) + path = self.directory / f"{identifier}-test.md" + path.write_bytes(adr(identifier, **kwargs).encode("utf-8")) + return path + + def commit(self) -> None: + self.git("add", ".") + self.git("commit", "-qm", "test: record fixture") + + +@pytest.fixture +def repo(tmp_path: Path) -> Repository: + root = tmp_path.resolve() / "repository with spaces" + root.mkdir() + result = Repository(root) + result.git("init", "-q", "-b", "main") + result.git("config", "user.name", "Fixture") + result.git("config", "user.email", "fixture@example.invalid") + result.git("config", "core.autocrlf", "false") + return result diff --git a/plugins/foundation/darrow-decisions/bin/decision.test.sh b/plugins/foundation/darrow-decisions/backend/tests/decision.test.sh old mode 100755 new mode 100644 similarity index 74% rename from plugins/foundation/darrow-decisions/bin/decision.test.sh rename to plugins/foundation/darrow-decisions/backend/tests/decision.test.sh index 197800a3..6ba51004 --- a/plugins/foundation/darrow-decisions/bin/decision.test.sh +++ b/plugins/foundation/darrow-decisions/backend/tests/decision.test.sh @@ -3,8 +3,8 @@ # relationships, bounded output, and read-only behavior. set -uo pipefail -SCRIPT="$(cd "$(dirname "$0")" && pwd)/decision" -SHELL_UNDER_TEST=${BASH:-bash} +PROJECT="$(cd "$(dirname "$0")/../.." && pwd)/backend" +DECISION=(uv run --quiet --frozen --no-dev --project "$PROJECT" darrow-decision) FAILURES=0 TEMP_REPOS="" @@ -104,17 +104,17 @@ git -C "$REPO" add docs/decisions git -C "$REPO" commit -qm "docs: add fixture decision" before=$(git -C "$REPO" status --porcelain --untracked-files=all) before_hash=$(git -C "$REPO" hash-object "$REPO/docs/decisions/ADR-0001-test.md") -out=$("$SHELL_UNDER_TEST" "$SCRIPT" inspect --repo "$REPO/src") +out=$("${DECISION[@]}" inspect --repo "$REPO/src") check_contains "resolves the repository root from a subdirectory" "root: $REPO" "$out" check_contains "reports an absolute ADR directory" "adr-directory: $REPO/docs/decisions" "$out" check_contains "counts ADRs" "adr-records: 1" "$out" -out=$("$SHELL_UNDER_TEST" "$SCRIPT" list --repo "$REPO" --search sqlite --status Accepted) +out=$("${DECISION[@]}" list --repo "$REPO" --search sqlite --status Accepted) check_contains "search is case-insensitive" "ADR-0001 Accepted architecture" "$out" check_contains "list paths are absolute" "$REPO/docs/decisions/ADR-0001-test.md" "$out" check_contains "list states its filters" "status=Accepted, search=sqlite" "$out" -out=$("$SHELL_UNDER_TEST" "$SCRIPT" list --repo "$REPO" --status Proposed) +out=$("${DECISION[@]}" list --repo "$REPO" --status Proposed) check_contains "empty filtered result is honest" "total: 0 (status=Proposed" "$out" -out=$("$SHELL_UNDER_TEST" "$SCRIPT" next-id --repo "$REPO" --title "Checkpointed local state") +out=$("${DECISION[@]}" next-id --repo "$REPO" --title "Checkpointed local state") check_contains "allocates the next identifier" "id: ADR-0002" "$out" check_contains "renders an absolute proposed path" "path: $REPO/docs/decisions/ADR-0002-checkpointed-local-state.md" "$out" after=$(git -C "$REPO" status --porcelain --untracked-files=all) @@ -127,7 +127,7 @@ fresh_repo write_adr ADR-0001 Accepted "SQLite for local state" write_adr ADR-0002 Proposed "PostgreSQL for hosted state" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" catalog rebuild --repo "$REPO" 2>&1) +out=$("${DECISION[@]}" catalog rebuild --repo "$REPO" 2>&1) rc=$? set -e check_equal "rebuilds a Markdown ADR catalog" 0 "$rc" @@ -136,10 +136,10 @@ catalog="$REPO/docs/decisions/README.md" check_file_exists "rebuild creates the checked-in catalog surface" "$catalog" if [[ -f "$catalog" ]]; then first_hash=$(git hash-object "$catalog") - out=$("$SHELL_UNDER_TEST" "$SCRIPT" catalog rebuild --repo "$REPO") + out=$("${DECISION[@]}" catalog rebuild --repo "$REPO") second_hash=$(git hash-object "$catalog") check_equal "repeated rebuilds produce identical bytes" "$first_hash" "$second_hash" - out=$("$SHELL_UNDER_TEST" "$SCRIPT" catalog check --repo "$REPO") + out=$("${DECISION[@]}" catalog check --repo "$REPO") check_contains "freshness check accepts the rebuilt catalog" "fresh: $catalog" "$out" check_contains "catalog identifies itself as derived" "derived, non-authoritative" "$(cat "$catalog")" check_contains "catalog visibly links the accepted ADR" "[ADR-0001: SQLite for local state](ADR-0001-test.md)" "$(cat "$catalog")" @@ -151,7 +151,7 @@ git -C "$REPO" commit -qm "docs: add generated catalog" cp "$REPO/docs/decisions/README.md" "$REPO/catalog.saved" git -C "$REPO" update-index --assume-unchanged docs/decisions/README.md printf '\n' >> "$REPO/docs/decisions/README.md" -out=$("$SHELL_UNDER_TEST" "$SCRIPT" list --repo "$REPO" --status Accepted 2>&1) +out=$("${DECISION[@]}" list --repo "$REPO" --status Accepted 2>&1) check_contains "assume-unchanged catalog warns as stale" "warning: ADR catalog is stale" "$out" check_contains "assume-unchanged catalog falls back to full scan" "ADR-0001 Accepted" "$out" git -C "$REPO" update-index --no-assume-unchanged docs/decisions/README.md @@ -160,53 +160,33 @@ git -C "$REPO" update-index --refresh >/dev/null echo "fresh catalog inventory routing and deliberate full-text body scan" if [[ -f "$catalog" ]]; then - mkdir "$REPO/read-bin" - real_awk=$(command -v awk) - real_grep=$(command -v grep) - real_git=$(command -v git) - # shellcheck disable=SC2016 # generated wrappers intentionally expand at runtime - printf '#!/bin/sh\nfor arg in "$@"; do case "$arg" in */docs/decisions/ADR-*.md) printf "awk\\t%%s\\n" "$arg" >> "$ADR_READ_LOG";; esac; done\nexec %s "$@"\n' "$real_awk" > "$REPO/read-bin/awk" - # shellcheck disable=SC2016 # generated wrappers intentionally expand at runtime - printf '#!/bin/sh\nfor arg in "$@"; do case "$arg" in */docs/decisions/ADR-*.md) printf "grep\\t%%s\\n" "$arg" >> "$ADR_READ_LOG";; esac; done\nexec %s "$@"\n' "$real_grep" > "$REPO/read-bin/grep" - # shellcheck disable=SC2016 # generated wrapper intentionally expands at runtime - printf '#!/bin/sh\nseen_hash=0\nfor arg in "$@"; do [ "$arg" != hash-object ] || seen_hash=1; if [ "$seen_hash" -eq 1 ]; then case "$arg" in */docs/decisions/ADR-*.md) printf "git-hash-object\\t%%s\\n" "$arg" >> "$ADR_READ_LOG";; esac; fi; done\nexec %s "$@"\n' "$real_git" > "$REPO/read-bin/git" - chmod +x "$REPO/read-bin/awk" "$REPO/read-bin/grep" "$REPO/read-bin/git" - : > "$REPO/adr-reads" - out=$(ADR_READ_LOG="$REPO/adr-reads" PATH="$REPO/read-bin:$PATH" "$SHELL_UNDER_TEST" "$SCRIPT" list --repo "$REPO" --status Accepted) - check_contains "catalog listing preserves the metadata match" "ADR-0001 Accepted" "$out" - check_not_contains "catalog listing omits the filtered status" "ADR-0002 Proposed" "$out" - reads=$(awk '$1 == "awk" || $1 == "grep" {count++} END {print count + 0}' "$REPO/adr-reads") - check_equal "fresh catalog answers metadata filters without ADR parsing or search" 0 "$reads" cp "$REPO/docs/decisions/ADR-0001-test.md" "$REPO/ADR-0001.saved" git -C "$REPO" update-index --assume-unchanged docs/decisions/ADR-0001-test.md printf '\nHidden worktree mutation.\n' >> "$REPO/docs/decisions/ADR-0001-test.md" - out=$("$SHELL_UNDER_TEST" "$SCRIPT" list --repo "$REPO" --status Accepted 2>&1) + out=$("${DECISION[@]}" list --repo "$REPO" --status Accepted 2>&1) check_contains "assume-unchanged ADR warns as stale" "warning: ADR catalog is stale" "$out" check_contains "assume-unchanged ADR falls back to full scan" "ADR-0001 Accepted" "$out" - "$SHELL_UNDER_TEST" "$SCRIPT" catalog rebuild --repo "$REPO" >/dev/null + "${DECISION[@]}" catalog rebuild --repo "$REPO" >/dev/null expected_fingerprint=$(git -C "$REPO" hash-object --path=docs/decisions/ADR-0001-test.md "$REPO/docs/decisions/ADR-0001-test.md") actual_fingerprint=$(sed -n '/ADR-0001-test\.md -->$/s/^<!-- darrow-source: \([0-9a-f]*\) .*/\1/p' "$REPO/docs/decisions/README.md") check_equal "hidden ADR rebuild records the worktree blob fingerprint" "$expected_fingerprint" "$actual_fingerprint" set +e - out=$("$SHELL_UNDER_TEST" "$SCRIPT" catalog check --repo "$REPO" 2>&1) + out=$("${DECISION[@]}" catalog check --repo "$REPO" 2>&1) rc=$? set -e check_equal "rebuild fingerprints hidden ADR worktree content" 0 "$rc" check_contains "hidden ADR rebuild becomes fresh" "records: 2" "$out" git -C "$REPO" update-index --no-assume-unchanged docs/decisions/ADR-0001-test.md mv "$REPO/ADR-0001.saved" "$REPO/docs/decisions/ADR-0001-test.md" - : > "$REPO/adr-reads" - out=$("$SHELL_UNDER_TEST" "$SCRIPT" list --repo "$REPO" --search "SQLite for local") + out=$("${DECISION[@]}" list --repo "$REPO" --search "SQLite for local") check_contains "full-body scan preserves literal phrases" "ADR-0001 Accepted" "$out" - out=$("$SHELL_UNDER_TEST" "$SCRIPT" list --repo "$REPO" --search "SQLite hosted") + out=$("${DECISION[@]}" list --repo "$REPO" --search "SQLite hosted") check_contains "separate body terms do not become false exact matches" "total: 0" "$out" printf '\nA body-only quasar marker.\n' >> "$REPO/docs/decisions/ADR-0002-test.md" - "$SHELL_UNDER_TEST" "$SCRIPT" catalog rebuild --repo "$REPO" >/dev/null - : > "$REPO/adr-reads" - out=$(ADR_READ_LOG="$REPO/adr-reads" PATH="$REPO/read-bin:$PATH" "$SHELL_UNDER_TEST" "$SCRIPT" list --repo "$REPO" --search quasar) + "${DECISION[@]}" catalog rebuild --repo "$REPO" >/dev/null + out=$("${DECISION[@]}" list --repo "$REPO" --search quasar) check_contains "term absent from summaries remains an exact match" "ADR-0002 Proposed" "$out" - reads=$(awk 'END {print NR + 0}' "$REPO/adr-reads") - check_equal "full-text search deliberately scans every ADR body" 4 "$reads" + fi echo "canonical fingerprints despite content-transforming Git filters" @@ -214,7 +194,7 @@ fresh_repo printf 'docs/decisions/ADR-*.md filter=accepted-status\n' > "$REPO/.gitattributes" git -C "$REPO" config filter.accepted-status.clean 'sed "s/^Status: .*/Status: Accepted/"' write_adr ADR-0001 Accepted "Filtered routing" -"$SHELL_UNDER_TEST" "$SCRIPT" catalog rebuild --repo "$REPO" >/dev/null +"${DECISION[@]}" catalog rebuild --repo "$REPO" >/dev/null git -C "$REPO" add .gitattributes docs/decisions git -C "$REPO" commit -qm "docs: catalog with status-normalizing filter" write_adr ADR-0001 Rejected "Filtered routing" @@ -226,18 +206,18 @@ set -e check_equal "clean filter hides changed status from refreshed Git index" 0 "$rc" before_hash=$(git -C "$REPO" hash-object --no-filters "$REPO/docs/decisions/ADR-0001-test.md") before_catalog=$(git -C "$REPO" hash-object --no-filters "$REPO/docs/decisions/README.md") -out=$("$SHELL_UNDER_TEST" "$SCRIPT" list --repo "$REPO" --status Accepted 2>&1) +out=$("${DECISION[@]}" list --repo "$REPO" --status Accepted 2>&1) check_contains "filtered ADR mutation warns as stale" "warning: ADR catalog is stale" "$out" check_contains "stale Accepted metadata cannot invent a match" "total: 0 (status=Accepted" "$out" check_not_contains "rejected ADR is absent from accepted matches" "ADR-0001 Accepted" "$out" -out=$("$SHELL_UNDER_TEST" "$SCRIPT" list --repo "$REPO" --status Rejected 2>&1) +out=$("${DECISION[@]}" list --repo "$REPO" --status Rejected 2>&1) check_contains "stale catalog cannot hide the new status match" "ADR-0001 Rejected" "$out" -out=$("$SHELL_UNDER_TEST" "$SCRIPT" list --repo "$REPO" --search "Filtered routing" 2>&1) +out=$("${DECISION[@]}" list --repo "$REPO" --search "Filtered routing" 2>&1) check_contains "metadata listing agrees with canonical body search" "ADR-0001 Rejected" "$out" for operation in "catalog check" validate; do set +e # shellcheck disable=SC2086 # the fixed operation supplies one or two arguments - out=$("$SHELL_UNDER_TEST" "$SCRIPT" $operation --repo "$REPO" 2>&1) + out=$("${DECISION[@]}" $operation --repo "$REPO" 2>&1) rc=$? set -e check_equal "$operation rejects status hidden by a clean filter" 4 "$rc" @@ -245,20 +225,20 @@ for operation in "catalog check" validate; do done check_equal "filtered discovery preserves canonical ADR bytes" "$before_hash" "$(git -C "$REPO" hash-object --no-filters "$REPO/docs/decisions/ADR-0001-test.md")" check_equal "filtered discovery preserves catalog bytes" "$before_catalog" "$(git -C "$REPO" hash-object --no-filters "$REPO/docs/decisions/README.md")" -"$SHELL_UNDER_TEST" "$SCRIPT" catalog rebuild --repo "$REPO" >/dev/null +"${DECISION[@]}" catalog rebuild --repo "$REPO" >/dev/null actual_fingerprint=$(sed -n '/ADR-0001-test\.md -->$/s/^<!-- darrow-source: \([0-9a-f]*\) .*/\1/p' "$REPO/docs/decisions/README.md") check_equal "rebuild fingerprints canonical bytes without clean conversion" "$before_hash" "$actual_fingerprint" -"$SHELL_UNDER_TEST" "$SCRIPT" catalog check --repo "$REPO" >/dev/null +"${DECISION[@]}" catalog check --repo "$REPO" >/dev/null git -C "$REPO" add docs/decisions git -C "$REPO" commit -qm "docs: catalog canonical rejected status" -out=$("$SHELL_UNDER_TEST" "$SCRIPT" list --repo "$REPO" --status Rejected 2>&1) +out=$("${DECISION[@]}" list --repo "$REPO" --status Rejected 2>&1) check_contains "raw fingerprint catalog lists the current status" "ADR-0001 Rejected" "$out" check_not_contains "raw fingerprint catalog remains fresh with a clean filter" "warning:" "$out" echo "canonical catalog bytes despite a content-transforming Git filter" fresh_repo write_adr ADR-0001 Accepted "Catalog filtering" -"$SHELL_UNDER_TEST" "$SCRIPT" catalog rebuild --repo "$REPO" >/dev/null +"${DECISION[@]}" catalog rebuild --repo "$REPO" >/dev/null cp "$REPO/docs/decisions/README.md" "$REPO/.git/catalog-original" printf 'docs/decisions/README.md filter=original-catalog\n' > "$REPO/.gitattributes" git -C "$REPO" config filter.original-catalog.clean 'cat .git/catalog-original' @@ -271,14 +251,14 @@ git -C "$REPO" diff --quiet HEAD -- docs/decisions/README.md rc=$? set -e check_equal "clean filter hides modified catalog from Git" 0 "$rc" -out=$("$SHELL_UNDER_TEST" "$SCRIPT" list --repo "$REPO" --status Accepted 2>&1) +out=$("${DECISION[@]}" list --repo "$REPO" --status Accepted 2>&1) check_contains "filtered catalog mutation warns as stale" "warning: ADR catalog is stale" "$out" check_contains "filtered catalog fallback preserves matches" "ADR-0001 Accepted" "$out" echo "legacy filtered fingerprints cannot authenticate current metadata" fresh_repo write_adr ADR-0001 Rejected "Legacy routing" -"$SHELL_UNDER_TEST" "$SCRIPT" catalog rebuild --repo "$REPO" >/dev/null +"${DECISION[@]}" catalog rebuild --repo "$REPO" >/dev/null # Retain an actual v1 catalog: Rejected metadata, the clean-filtered Accepted # blob identity, and its valid row checksum. The current Accepted bytes match # that old identity but must not authenticate the old Rejected metadata. @@ -294,20 +274,20 @@ sed -e 's/darrow-adr-catalog-v2/darrow-adr-catalog-v1/' \ mv "$REPO/legacy-catalog" "$REPO/docs/decisions/README.md" git -C "$REPO" add docs/decisions git -C "$REPO" commit -qm "docs: legacy filtered catalog" -out=$("$SHELL_UNDER_TEST" "$SCRIPT" list --repo "$REPO" --status Accepted 2>&1) +out=$("${DECISION[@]}" list --repo "$REPO" --status Accepted 2>&1) check_contains "legacy catalog warns before scanning" "warning: ADR catalog" "$out" check_contains "legacy fingerprint cannot hide a current match" "ADR-0001 Accepted" "$out" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" catalog check --repo "$REPO" 2>&1) +out=$("${DECISION[@]}" catalog check --repo "$REPO" 2>&1) rc=$? set -e check_equal "legacy catalog requires explicit upgrade" 4 "$rc" -"$SHELL_UNDER_TEST" "$SCRIPT" catalog rebuild --repo "$REPO" >/dev/null +"${DECISION[@]}" catalog rebuild --repo "$REPO" >/dev/null check_contains "rebuild upgrades a recognized legacy catalog" "<!-- darrow-adr-catalog-v2 -->" "$(cat "$REPO/docs/decisions/README.md")" -"$SHELL_UNDER_TEST" "$SCRIPT" catalog check --repo "$REPO" >/dev/null +"${DECISION[@]}" catalog check --repo "$REPO" >/dev/null git -C "$REPO" add docs/decisions/README.md git -C "$REPO" commit -qm "docs: upgrade derived catalog" -out=$("$SHELL_UNDER_TEST" "$SCRIPT" list --repo "$REPO" --status Accepted 2>&1) +out=$("${DECISION[@]}" list --repo "$REPO" --status Accepted 2>&1) check_contains "upgraded catalog preserves current metadata" "ADR-0001 Accepted" "$out" check_not_contains "upgraded committed catalog is fresh" "warning:" "$out" @@ -316,23 +296,23 @@ fresh_repo write_adr ADR-0001 Accepted "Alpha storage" write_adr ADR-0002 Accepted "Beta transport" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" list --repo "$REPO" --status Accepted 2>&1) +out=$("${DECISION[@]}" list --repo "$REPO" --status Accepted 2>&1) rc=$? set -e check_equal "missing catalog remains compatible" 0 "$rc" check_contains "missing catalog warns before full scan" "warning: ADR catalog is missing" "$out" check_contains "missing catalog full scan preserves matches" "ADR-0001 Accepted" "$out" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" validate --repo "$REPO" 2>&1) +out=$("${DECISION[@]}" validate --repo "$REPO" 2>&1) rc=$? set -e check_equal "validation rejects a missing catalog" 4 "$rc" check_contains "missing catalog validation is explicit" "ADR catalog is missing" "$out" -"$SHELL_UNDER_TEST" "$SCRIPT" catalog rebuild --repo "$REPO" >/dev/null 2>&1 +"${DECISION[@]}" catalog rebuild --repo "$REPO" >/dev/null 2>&1 write_adr ADR-0003 Accepted "Gamma queue" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" list --repo "$REPO" --status Accepted 2>&1) +out=$("${DECISION[@]}" list --repo "$REPO" --status Accepted 2>&1) rc=$? set -e check_equal "stale catalog membership falls back successfully" 0 "$rc" @@ -340,31 +320,31 @@ check_contains "stale membership warns" "warning: ADR catalog is stale" "$out" check_contains "stale catalog cannot hide a new ADR" "ADR-0003 Accepted" "$out" printf '\nA newly recorded zephyr constraint.\n' >> "$REPO/docs/decisions/ADR-0002-test.md" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" list --repo "$REPO" --search zephyr 2>&1) +out=$("${DECISION[@]}" list --repo "$REPO" --search zephyr 2>&1) rc=$? set -e check_equal "stale catalog falls back successfully" 0 "$rc" check_contains "stale catalog warns" "warning: ADR catalog is stale" "$out" check_contains "stale data cannot hide a new body match" "ADR-0002 Accepted" "$out" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" catalog check --repo "$REPO" 2>&1) +out=$("${DECISION[@]}" catalog check --repo "$REPO" 2>&1) rc=$? set -e check_equal "explicit freshness check rejects stale data" 4 "$rc" printf '# Broken catalog\n\n<!-- darrow-adr-catalog-v2 -->\n' > "$REPO/docs/decisions/README.md" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" list --repo "$REPO" --search Alpha 2>&1) +out=$("${DECISION[@]}" list --repo "$REPO" --search Alpha 2>&1) rc=$? set -e check_equal "malformed catalog falls back successfully" 0 "$rc" check_contains "modified malformed catalog warns" "warning: ADR catalog is stale" "$out" check_contains "malformed catalog cannot hide matches" "ADR-0001 Accepted" "$out" -"$SHELL_UNDER_TEST" "$SCRIPT" catalog rebuild --repo "$REPO" >/dev/null 2>&1 +"${DECISION[@]}" catalog rebuild --repo "$REPO" >/dev/null 2>&1 chmod 000 "$REPO/docs/decisions/README.md" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" list --repo "$REPO" --search Alpha 2>&1) +out=$("${DECISION[@]}" list --repo "$REPO" --search Alpha 2>&1) rc=$? set -e chmod 600 "$REPO/docs/decisions/README.md" @@ -376,22 +356,22 @@ else check_contains "unreadable catalog cannot hide matches" "ADR-0001 Accepted" "$out" fi -"$SHELL_UNDER_TEST" "$SCRIPT" catalog rebuild --repo "$REPO" >/dev/null 2>&1 +"${DECISION[@]}" catalog rebuild --repo "$REPO" >/dev/null 2>&1 printf '\nAnother stale change.\n' >> "$REPO/docs/decisions/ADR-0001-test.md" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" validate --repo "$REPO" 2>&1) +out=$("${DECISION[@]}" validate --repo "$REPO" 2>&1) rc=$? set -e check_equal "validation rejects checked-in catalog drift" 4 "$rc" check_contains "validation names stale catalog" "ADR catalog is stale" "$out" -"$SHELL_UNDER_TEST" "$SCRIPT" catalog rebuild --repo "$REPO" >/dev/null 2>&1 +"${DECISION[@]}" catalog rebuild --repo "$REPO" >/dev/null 2>&1 git -C "$REPO" add docs/decisions git -C "$REPO" commit -qm "docs: add catalog fixture" sed 's/| Accepted |/| Proposed |/' "$REPO/docs/decisions/README.md" > "$REPO/docs/decisions/README.md.new" mv "$REPO/docs/decisions/README.md.new" "$REPO/docs/decisions/README.md" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" list --repo "$REPO" --status Accepted 2>&1) +out=$("${DECISION[@]}" list --repo "$REPO" --status Accepted 2>&1) rc=$? set -e check_equal "modified catalog falls back successfully" 0 "$rc" @@ -399,11 +379,11 @@ check_contains "modified catalog warns as stale" "warning: ADR catalog is stale" check_contains "modified catalog cannot forge status metadata" "ADR-0001 Accepted" "$out" fresh_repo -"$SHELL_UNDER_TEST" "$SCRIPT" catalog rebuild --repo "$REPO" >/dev/null +"${DECISION[@]}" catalog rebuild --repo "$REPO" >/dev/null sed '/darrow-adr-catalog-v2/d' "$REPO/docs/decisions/README.md" > "$REPO/docs/decisions/README.md.new" mv "$REPO/docs/decisions/README.md.new" "$REPO/docs/decisions/README.md" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" validate --repo "$REPO" 2>&1) +out=$("${DECISION[@]}" validate --repo "$REPO" 2>&1) rc=$? set -e check_equal "validation rejects malformed empty catalog" 4 "$rc" @@ -411,14 +391,14 @@ check_contains "empty catalog validation names malformed data" "ADR catalog is m fresh_repo write_adr ADR-0001 Accepted "Rendered table" -"$SHELL_UNDER_TEST" "$SCRIPT" catalog rebuild --repo "$REPO" >/dev/null +"${DECISION[@]}" catalog rebuild --repo "$REPO" >/dev/null sed -e '/^| Decision | Status | Date | Summary | Relationships |$/d' -e '/^| --- | --- | --- | --- | --- |$/d' \ "$REPO/docs/decisions/README.md" > "$REPO/docs/decisions/README.md.new" mv "$REPO/docs/decisions/README.md.new" "$REPO/docs/decisions/README.md" git -C "$REPO" add docs/decisions git -C "$REPO" commit -qm "docs: commit headerless catalog fixture" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" list --repo "$REPO" --status Accepted 2>&1) +out=$("${DECISION[@]}" list --repo "$REPO" --status Accepted 2>&1) rc=$? set -e check_equal "headerless catalog falls back successfully" 0 "$rc" @@ -427,14 +407,14 @@ check_contains "headerless catalog cannot hide a record" "ADR-0001 Accepted" "$o fresh_repo write_adr ADR-0001 Accepted "Closed grammar" -"$SHELL_UNDER_TEST" "$SCRIPT" catalog rebuild --repo "$REPO" >/dev/null +"${DECISION[@]}" catalog rebuild --repo "$REPO" >/dev/null sed '1a\ Injected catalog prose.' "$REPO/docs/decisions/README.md" > "$REPO/docs/decisions/README.md.new" mv "$REPO/docs/decisions/README.md.new" "$REPO/docs/decisions/README.md" git -C "$REPO" add docs/decisions git -C "$REPO" commit -qm "docs: commit injected catalog fixture" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" list --repo "$REPO" --status Accepted 2>&1) +out=$("${DECISION[@]}" list --repo "$REPO" --status Accepted 2>&1) rc=$? set -e check_equal "injected catalog prose falls back successfully" 0 "$rc" @@ -446,7 +426,7 @@ fresh_repo printf '# Team decision guide\n' > "$REPO/docs/decisions/README.md" write_adr ADR-0001 Accepted "First" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" catalog rebuild --repo "$REPO" 2>&1) +out=$("${DECISION[@]}" catalog rebuild --repo "$REPO" 2>&1) rc=$? set -e check_equal "human-authored README replacement is refused" 4 "$rc" @@ -456,9 +436,9 @@ check_equal "human-authored README remains intact" "# Team decision guide" "$(ca fresh_repo write_adr ADR-0001 Accepted "Pipe | title <visible>" mv "$REPO/docs/decisions/ADR-0001-test.md" "$REPO/docs/decisions/ADR-0001-special#query?-->.md" -out=$("$SHELL_UNDER_TEST" "$SCRIPT" catalog rebuild --repo "$REPO") +out=$("${DECISION[@]}" catalog rebuild --repo "$REPO") check_contains "catalog rebuild accepts escapable Markdown names" "records: 1" "$out" -out=$("$SHELL_UNDER_TEST" "$SCRIPT" catalog check --repo "$REPO") +out=$("${DECISION[@]}" catalog check --repo "$REPO") check_contains "escaped catalog validates" "records: 1" "$out" catalog_text=$(cat "$REPO/docs/decisions/README.md") check_contains "pipe in title stays in one table cell" 'ADR-0001: Pipe \| title <visible>' "$catalog_text" @@ -466,29 +446,29 @@ check_contains "HTML delimiters remain visible metadata" 'Use Pipe \| title < check_contains "special link target is canonical" 'ADR-0001-special%23query%3F--%3E.md' "$catalog_text" check_not_contains "source filename cannot terminate its comment" 'query?-->.md -->' "$catalog_text" mv "$REPO/docs/decisions/ADR-0001-special#query?-->.md" "$REPO/docs/decisions/ADR-0001-special|pipe.md" -"$SHELL_UNDER_TEST" "$SCRIPT" catalog rebuild --repo "$REPO" >/dev/null -out=$("$SHELL_UNDER_TEST" "$SCRIPT" catalog check --repo "$REPO") +"${DECISION[@]}" catalog rebuild --repo "$REPO" >/dev/null +out=$("${DECISION[@]}" catalog check --repo "$REPO") check_contains "pipe filename catalog validates" "records: 1" "$out" check_contains "pipe filename link is canonical" 'ADR-0001-special%7Cpipe.md' "$(cat "$REPO/docs/decisions/README.md")" mv "$REPO/docs/decisions/ADR-0001-special|pipe.md" "$REPO/docs/decisions/ADR-0001-back\\slash-é.md" -"$SHELL_UNDER_TEST" "$SCRIPT" catalog rebuild --repo "$REPO" >/dev/null +"${DECISION[@]}" catalog rebuild --repo "$REPO" >/dev/null git -C "$REPO" add docs/decisions git -C "$REPO" commit -qm "docs: commit byte-safe catalog fixture" -out=$("$SHELL_UNDER_TEST" "$SCRIPT" list --repo "$REPO" --status Accepted 2>&1) +out=$("${DECISION[@]}" list --repo "$REPO" --status Accepted 2>&1) check_contains "fresh catalog accepts backslash and non-ASCII filename" "ADR-0001 Accepted" "$out" check_not_contains "byte-safe filename remains on catalog fast path" "using full scan" "$out" echo "lifecycle transitions" -out=$("$SHELL_UNDER_TEST" "$SCRIPT" check-transition --from Proposed --to Accepted) +out=$("${DECISION[@]}" check-transition --from Proposed --to Accepted) check_contains "allows proposal acceptance" "Proposed -> Accepted: allowed" "$out" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" check-transition --from Rejected --to Accepted 2>&1) +out=$("${DECISION[@]}" check-transition --from Rejected --to Accepted 2>&1) rc=$? set -e check_equal "rejects terminal transition" 3 "$rc" check_contains "names the rejected transition" "Rejected -> Accepted" "$out" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" check-transition --from Draft --to Accepted 2>&1) +out=$("${DECISION[@]}" check-transition --from Draft --to Accepted 2>&1) rc=$? set -e check_equal "rejects unsupported status" 2 "$rc" @@ -498,10 +478,10 @@ echo "valid reciprocal supersession" fresh_repo write_adr ADR-0001 Superseded "Files for local state" "" "ADR-0002" write_adr ADR-0002 Accepted "SQLite for local state" "ADR-0001" -"$SHELL_UNDER_TEST" "$SCRIPT" catalog rebuild --repo "$REPO" >/dev/null -out=$("$SHELL_UNDER_TEST" "$SCRIPT" validate --repo "$REPO") +"${DECISION[@]}" catalog rebuild --repo "$REPO" >/dev/null +out=$("${DECISION[@]}" validate --repo "$REPO") check_contains "accepts reciprocal relations" "valid: 2 ADR(s)" "$out" -out=$("$SHELL_UNDER_TEST" "$SCRIPT" list --repo "$REPO" --related-to ADR-0001) +out=$("${DECISION[@]}" list --repo "$REPO" --related-to ADR-0001) check_contains "filters by relationship" "ADR-0002 Accepted" "$out" check_contains "shows supersedes relation" "supersedes: ADR-0001" "$out" @@ -510,8 +490,8 @@ for replacement_status in Deprecated Superseded; do fresh_repo write_adr ADR-0001 Superseded "Original choice" "" "ADR-0002" write_adr ADR-0002 Accepted "Intermediate choice" "ADR-0001" - "$SHELL_UNDER_TEST" "$SCRIPT" catalog rebuild --repo "$REPO" >/dev/null - out=$("$SHELL_UNDER_TEST" "$SCRIPT" check-transition --from Accepted --to "$replacement_status") + "${DECISION[@]}" catalog rebuild --repo "$REPO" >/dev/null + out=$("${DECISION[@]}" check-transition --from Accepted --to "$replacement_status") check_contains "allows replacement transition to $replacement_status" "allowed" "$out" count=2 if [[ "$replacement_status" == Superseded ]]; then @@ -522,18 +502,18 @@ for replacement_status in Deprecated Superseded; do write_adr ADR-0002 Deprecated "Intermediate choice" "ADR-0001" fi set +e - out=$("$SHELL_UNDER_TEST" "$SCRIPT" catalog rebuild --repo "$REPO" 2>&1) + out=$("${DECISION[@]}" catalog rebuild --repo "$REPO" 2>&1) rc=$? set -e check_equal "rebuild accepts a $replacement_status historical replacement" 0 "$rc" set +e - out=$("$SHELL_UNDER_TEST" "$SCRIPT" validate --repo "$REPO" 2>&1) + out=$("${DECISION[@]}" validate --repo "$REPO" 2>&1) rc=$? set -e check_equal "validates $replacement_status historical relationships" 0 "$rc" check_contains "validates every record in the $replacement_status chain" "valid: $count ADR(s)" "$out" set +e - out=$("$SHELL_UNDER_TEST" "$SCRIPT" list --repo "$REPO" --related-to ADR-0001 2>&1) + out=$("${DECISION[@]}" list --repo "$REPO" --related-to ADR-0001 2>&1) rc=$? set -e check_equal "inventories $replacement_status historical relationships" 0 "$rc" @@ -547,7 +527,7 @@ for replacement_status in Proposed Rejected; do write_adr ADR-0001 Superseded "Original choice" "" "ADR-0002" write_adr ADR-0002 "$replacement_status" "Never accepted" "ADR-0001" set +e - out=$("$SHELL_UNDER_TEST" "$SCRIPT" catalog rebuild --repo "$REPO" 2>&1) + out=$("${DECISION[@]}" catalog rebuild --repo "$REPO" 2>&1) rc=$? set -e check_equal "$replacement_status cannot replace an ADR" 4 "$rc" @@ -558,7 +538,7 @@ fresh_repo write_adr ADR-0001 Superseded "Original choice" "" "ADR-0002" write_adr ADR-0002 Deprecated "Missing reciprocal history" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" catalog rebuild --repo "$REPO" 2>&1) +out=$("${DECISION[@]}" catalog rebuild --repo "$REPO" 2>&1) rc=$? set -e check_equal "historical replacement still requires reciprocity" 4 "$rc" @@ -566,7 +546,7 @@ check_contains "names missing historical reciprocal link" "does not reciprocate write_adr ADR-0001 Superseded "First historical choice" "ADR-0002" "ADR-0002" write_adr ADR-0002 Superseded "Second historical choice" "ADR-0001" "ADR-0001" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" catalog rebuild --repo "$REPO" 2>&1) +out=$("${DECISION[@]}" catalog rebuild --repo "$REPO" 2>&1) rc=$? set -e check_equal "reciprocal historical cycle remains invalid" 4 "$rc" @@ -577,7 +557,7 @@ fresh_repo write_adr ADR-0001 Accepted "First choice" "ADR-0002" write_adr ADR-0002 Accepted "Second choice" "ADR-0001" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" validate --repo "$REPO" 2>&1) +out=$("${DECISION[@]}" validate --repo "$REPO" 2>&1) rc=$? set -e check_equal "invalid relationships fail validation" 4 "$rc" @@ -590,7 +570,7 @@ write_adr ADR-0001 Accepted "Missing summary" sed '/^Summary:/d' "$REPO/docs/decisions/ADR-0001-test.md" > "$REPO/docs/decisions/ADR-0001-test.md.new" mv "$REPO/docs/decisions/ADR-0001-test.md.new" "$REPO/docs/decisions/ADR-0001-test.md" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" validate --repo "$REPO" 2>&1) +out=$("${DECISION[@]}" validate --repo "$REPO" 2>&1) rc=$? set -e check_equal "missing canonical Summary fails" 4 "$rc" @@ -602,7 +582,7 @@ sed '/^Summary:/a\ Summary: A conflicting second summary.' "$REPO/docs/decisions/ADR-0001-test.md" > "$REPO/docs/decisions/ADR-0001-test.md.new" mv "$REPO/docs/decisions/ADR-0001-test.md.new" "$REPO/docs/decisions/ADR-0001-test.md" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" validate --repo "$REPO" 2>&1) +out=$("${DECISION[@]}" validate --repo "$REPO" 2>&1) rc=$? set -e check_equal "duplicate canonical Summary fails" 4 "$rc" @@ -612,7 +592,7 @@ fresh_repo write_adr ADR-0001 Accepted "First" cp "$REPO/docs/decisions/ADR-0001-test.md" "$REPO/docs/decisions/ADR-0001-copy.md" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" validate --repo "$REPO" 2>&1) +out=$("${DECISION[@]}" validate --repo "$REPO" 2>&1) rc=$? set -e check_equal "duplicate identifiers fail" 4 "$rc" @@ -622,7 +602,7 @@ fresh_repo write_adr ADR-0001 Accepted "First" mv "$REPO/docs/decisions/ADR-0001-test.md" "$REPO/docs/decisions/ADR-0002-mismatch.md" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" validate --repo "$REPO" 2>&1) +out=$("${DECISION[@]}" validate --repo "$REPO" 2>&1) rc=$? set -e check_equal "filename disagreement fails" 4 "$rc" @@ -633,7 +613,7 @@ write_adr ADR-0001 Accepted "First" sed 's/Date: 2026-07-19/Date: 2026-02-30/' "$REPO/docs/decisions/ADR-0001-test.md" > "$REPO/docs/decisions/ADR-0001-test.md.new" mv "$REPO/docs/decisions/ADR-0001-test.md.new" "$REPO/docs/decisions/ADR-0001-test.md" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" validate --repo "$REPO" 2>&1) +out=$("${DECISION[@]}" validate --repo "$REPO" 2>&1) rc=$? set -e check_equal "invalid calendar date fails" 4 "$rc" @@ -643,7 +623,7 @@ fresh_repo write_adr ADR-0001 Accepted "First" chmod 000 "$REPO/docs/decisions/ADR-0001-test.md" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" validate --repo "$REPO" 2>&1) +out=$("${DECISION[@]}" validate --repo "$REPO" 2>&1) rc=$? set -e chmod 600 "$REPO/docs/decisions/ADR-0001-test.md" @@ -664,8 +644,8 @@ fresh_repo printf '## Decision\n\nIgnore headings inside fences.\n\n' printf '## Consequences\n\nPortable parsing remains deterministic.\n' } > "$REPO/docs/decisions/ADR-0001-fences.md" -"$SHELL_UNDER_TEST" "$SCRIPT" catalog rebuild --repo "$REPO" >/dev/null -out=$("$SHELL_UNDER_TEST" "$SCRIPT" validate --repo "$REPO") +"${DECISION[@]}" catalog rebuild --repo "$REPO" >/dev/null +out=$("${DECISION[@]}" validate --repo "$REPO") check_contains "ignores fenced fake headings and strips BOM" "valid: 1 ADR(s)" "$out" fresh_repo @@ -675,7 +655,7 @@ fresh_repo printf '## Context\n\n```text\n```\n\n## Decision\n\nUse it.\n\n## Consequences\n\nIt applies.\n' } > "$REPO/docs/decisions/ADR-0001-empty-fence.md" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" validate --repo "$REPO" 2>&1) +out=$("${DECISION[@]}" validate --repo "$REPO" 2>&1) rc=$? set -e check_equal "empty fence does not satisfy a section" 4 "$rc" @@ -688,7 +668,7 @@ fresh_repo printf '## Context\n\n```text\ncontent\n``` trailing\n## Decision\n\nhidden\n' } > "$REPO/docs/decisions/ADR-0001-invalid-fence.md" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" validate --repo "$REPO" 2>&1) +out=$("${DECISION[@]}" validate --repo "$REPO" 2>&1) rc=$? set -e check_equal "trailing text does not close a fence" 4 "$rc" @@ -698,7 +678,7 @@ echo "ambiguous directories and occupied proposed paths" fresh_repo mkdir -p "$REPO/adrs" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" inspect --repo "$REPO" 2>&1) +out=$("${DECISION[@]}" inspect --repo "$REPO" 2>&1) rc=$? set -e check_equal "ambiguous ADR directories require selection" 3 "$rc" @@ -709,14 +689,14 @@ fresh_repo mv "$REPO/docs/decisions" "$REPO/real-decisions" ln -s ../real-decisions "$REPO/docs/decisions" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" inspect --repo "$REPO" 2>&1) +out=$("${DECISION[@]}" inspect --repo "$REPO" 2>&1) rc=$? set -e check_equal "in-repository ADR directory symlink fails" 2 "$rc" check_contains "rejects in-repository ADR directory symlink" "ADR directory symlinks are not allowed" "$out" for bypass in docs/decisions/ docs/decisions/.; do set +e - out=$("$SHELL_UNDER_TEST" "$SCRIPT" inspect --repo "$REPO" --dir "$bypass" 2>&1) + out=$("${DECISION[@]}" inspect --repo "$REPO" --dir "$bypass" 2>&1) rc=$? set -e check_equal "explicit symlink directory bypass fails: $bypass" 2 "$rc" @@ -727,33 +707,18 @@ fresh_repo write_adr ADR-0001 Accepted "First" ln -s missing "$REPO/docs/decisions/ADR-0002-checkpointed-local-state.md" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" next-id --repo "$REPO" --title "Checkpointed local state" 2>&1) +out=$("${DECISION[@]}" next-id --repo "$REPO" --title "Checkpointed local state" 2>&1) rc=$? set -e check_equal "unsafe occupied ADR entry fails before allocation" 4 "$rc" check_contains "names occupied symlink ADR path" "ADR symlinks are not allowed" "$out" -fresh_repo -write_adr ADR-0001 Accepted "First" -mkdir "$REPO/mock-bin" -real_sed=$(command -v sed) -# shellcheck disable=SC2016 # $@/$?/$rc/$OCCUPY_PATH must stay literal for the generated stub -printf '#!/bin/sh\n%s "$@"\nrc=$?\n: > "$OCCUPY_PATH"\nexit "$rc"\n' "$real_sed" > "$REPO/mock-bin/sed" -chmod +x "$REPO/mock-bin/sed" -occupied="$REPO/docs/decisions/ADR-0002-checkpointed-local-state.md" -set +e -out=$(OCCUPY_PATH="$occupied" PATH="$REPO/mock-bin:$PATH" "$SHELL_UNDER_TEST" "$SCRIPT" next-id --repo "$REPO" --title "Checkpointed local state" 2>&1) -rc=$? -set -e -check_equal "late occupied proposed path fails" 4 "$rc" -check_contains "reports late occupied proposed path" "next ADR path is already occupied: $occupied" "$out" - echo "unsafe entries, bounded metadata, and relationship edge cases" fresh_repo printf '# unrelated notes\n' > "$REPO/docs/decisions/notes.md" mkdir "$REPO/docs/decisions/ADR-0002-directory.md" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" validate --repo "$REPO" 2>&1) +out=$("${DECISION[@]}" validate --repo "$REPO" 2>&1) rc=$? set -e check_equal "malformed Markdown and occupied directory fail" 4 "$rc" @@ -764,7 +729,7 @@ fresh_repo write_adr ADR-0001 Accepted "First" ln -s "$REPO/README.md" "$REPO/docs/decisions/ADR-0002-escape.md" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" validate --repo "$REPO" 2>&1) +out=$("${DECISION[@]}" validate --repo "$REPO" 2>&1) rc=$? set -e check_equal "symlinked ADR record fails" 4 "$rc" @@ -776,7 +741,7 @@ TEMP_REPOS="$TEMP_REPOS $outside" rmdir "$REPO/docs/decisions" ln -s "$outside" "$REPO/docs/decisions" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" inspect --repo "$REPO" 2>&1) +out=$("${DECISION[@]}" inspect --repo "$REPO" 2>&1) rc=$? set -e check_equal "symlinked ADR directory escape fails" 2 "$rc" @@ -789,7 +754,7 @@ printf '# ADR-0002: Bad escape\n' > "$REPO/docs/decisions/"$'ADR-0002-bad\033nam printf '# ADR-0003: Bad bell\n' > "$REPO/docs/decisions/"$'ADR-0003-bad\007name.md' printf '# ADR-0004: Bad delete\n' > "$REPO/docs/decisions/"$'ADR-0004-bad\177name.md' set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" validate --repo "$REPO" 2>&1) +out=$("${DECISION[@]}" validate --repo "$REPO" 2>&1) rc=$? set -e check_equal "control character filename fails" 4 "$rc" @@ -804,7 +769,7 @@ sed -e 's/Status: Accepted/Status: /' -e 's/Date: 2026-07-19/Date: 2026-07-19\ Supersedes: /' "$REPO/docs/decisions/ADR-0001-test.md" > "$REPO/docs/decisions/rewrite" mv "$REPO/docs/decisions/rewrite" "$REPO/docs/decisions/ADR-0001-test.md" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" validate --repo "$REPO" 2>&1) +out=$("${DECISION[@]}" validate --repo "$REPO" 2>&1) rc=$? set -e check_equal "blank metadata fails" 4 "$rc" @@ -814,7 +779,7 @@ fresh_repo write_adr ADR-0001 Superseded "Old" "" "ADR-0002" write_adr ADR-0002 Accepted "New" "ADR-0001, ADR-0001" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" validate --repo "$REPO" 2>&1) +out=$("${DECISION[@]}" validate --repo "$REPO" 2>&1) rc=$? set -e check_equal "duplicate relationship target fails" 4 "$rc" @@ -823,7 +788,7 @@ check_contains "reports duplicate relationship target" "duplicate Supersedes tar fresh_repo write_adr ADR-999999999999999999 Accepted "Last identifier" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" next-id --repo "$REPO" 2>&1) +out=$("${DECISION[@]}" next-id --repo "$REPO" 2>&1) rc=$? set -e check_equal "identifier exhaustion fails" 4 "$rc" @@ -832,7 +797,7 @@ check_contains "reports identifier exhaustion" "identifier space is exhausted" " fresh_repo write_adr ADR-1000000000000000000 Accepted "Too wide" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" validate --repo "$REPO" 2>&1) +out=$("${DECISION[@]}" validate --repo "$REPO" 2>&1) rc=$? set -e check_equal "wider than 18 digits fails" 4 "$rc" @@ -842,7 +807,7 @@ fresh_repo write_adr ADR-0001 Accepted "First" long_search=$(printf '%0201d' 0) set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" list --repo "$REPO" --search "$long_search" 2>&1) +out=$("${DECISION[@]}" list --repo "$REPO" --search "$long_search" 2>&1) rc=$? set -e check_equal "oversized search fails" 2 "$rc" @@ -850,7 +815,7 @@ check_contains "reports search cap" "--search must be at most 200 bytes" "$out" for control in $'\033' $'\007' $'\177'; do set +e - out=$("$SHELL_UNDER_TEST" "$SCRIPT" list --repo "$REPO" --search "missing${control}control" 2>&1) + out=$("${DECISION[@]}" list --repo "$REPO" --search "missing${control}control" 2>&1) rc=$? set -e check_equal "control byte search fails" 2 "$rc" @@ -867,7 +832,7 @@ write_adr ADR-0001 Accepted "$long_title" "$long_relation" "" "$long_revisit" sed "s/^Summary:.*/Summary: $long_summary/" "$REPO/docs/decisions/ADR-0001-test.md" > "$REPO/docs/decisions/ADR-0001-test.md.new" mv "$REPO/docs/decisions/ADR-0001-test.md.new" "$REPO/docs/decisions/ADR-0001-test.md" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" validate --repo "$REPO" 2>&1) +out=$("${DECISION[@]}" validate --repo "$REPO" 2>&1) rc=$? set -e check_equal "oversized metadata fails" 4 "$rc" @@ -876,46 +841,12 @@ check_contains "reports Summary cap" "Summary exceeds 500 bytes" "$out" check_contains "reports relation cap" "Supersedes exceeds 1000 bytes" "$out" check_contains "reports revisit cap" "Revisit when exceeds 500 bytes" "$out" -echo "deterministic tool failure handling" -fresh_repo -mkdir "$REPO/mock-bin" -printf '#!/bin/sh\nexit 7\n' > "$REPO/mock-bin/find" -chmod +x "$REPO/mock-bin/find" -set +e -out=$(PATH="$REPO/mock-bin:$PATH" "$SHELL_UNDER_TEST" "$SCRIPT" validate --repo "$REPO" 2>&1) -rc=$? -set -e -check_equal "find failure is refused" 4 "$rc" -check_contains "names find failure" "cannot enumerate Markdown entries" "$out" - -printf '#!/bin/sh\nexit 7\n' > "$REPO/mock-bin/sort" -chmod +x "$REPO/mock-bin/sort" -rm "$REPO/mock-bin/find" -write_adr ADR-0001 Accepted "First" -set +e -out=$(PATH="$REPO/mock-bin:$PATH" "$SHELL_UNDER_TEST" "$SCRIPT" validate --repo "$REPO" 2>&1) -rc=$? -set -e -check_equal "sort failure is refused" 4 "$rc" -check_contains "names sort failure" "cannot sort ADR paths" "$out" - -real_awk=$(command -v awk) -printf '#!/bin/sh\ncase "$*" in *ADR-0001-test.md*) exit 7;; esac\nexec %s "$@"\n' "$real_awk" > "$REPO/mock-bin/awk" -chmod +x "$REPO/mock-bin/awk" -rm "$REPO/mock-bin/sort" -set +e -out=$(PATH="$REPO/mock-bin:$PATH" "$SHELL_UNDER_TEST" "$SCRIPT" validate --repo "$REPO" 2>&1) -rc=$? -set -e -check_equal "ADR parser failure is refused" 4 "$rc" -check_contains "names ADR parser failure" "cannot parse ADR file" "$out" - echo "deterministic malformed path diagnostics" fresh_repo printf '# malformed z\n' > "$REPO/docs/decisions/z.md" printf '# malformed a\n' > "$REPO/docs/decisions/a.md" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" validate --repo "$REPO" 2>&1) +out=$("${DECISION[@]}" validate --repo "$REPO" 2>&1) rc=$? set -e check_equal "malformed paths fail" 4 "$rc" @@ -927,13 +858,13 @@ write_adr ADR-0001 Accepted "Canonical path" record="$REPO/docs/decisions/ADR-0001-test.md" before=$(git -C "$REPO" status --porcelain --untracked-files=all) before_hash=$(git -C "$REPO" hash-object "$record") -out=$("$SHELL_UNDER_TEST" "$SCRIPT" canonical-path --repo "$REPO" --path docs/decisions/ADR-0001-test.md) +out=$("${DECISION[@]}" canonical-path --repo "$REPO" --path docs/decisions/ADR-0001-test.md) check_equal "resolves relative record path" "path: $record" "$out" -out=$("$SHELL_UNDER_TEST" "$SCRIPT" canonical-path --repo "$REPO" --path "$record") +out=$("${DECISION[@]}" canonical-path --repo "$REPO" --path "$record") check_equal "preserves absolute record path" "path: $record" "$out" ln -s "$record" "$REPO/docs/decisions/linked.md" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" canonical-path --repo "$REPO" --path docs/decisions/linked.md 2>&1) +out=$("${DECISION[@]}" canonical-path --repo "$REPO" --path docs/decisions/linked.md 2>&1) rc=$? set -e check_equal "symlinked canonical record is refused" 2 "$rc" @@ -941,7 +872,7 @@ check_contains "names symlink refusal" "canonical record symlinks are not allowe rm "$REPO/docs/decisions/linked.md" ln -s "$REPO/docs/decisions" "$REPO/linked-decisions" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" canonical-path --repo "$REPO" --path linked-decisions/ADR-0001-test.md 2>&1) +out=$("${DECISION[@]}" canonical-path --repo "$REPO" --path linked-decisions/ADR-0001-test.md 2>&1) rc=$? set -e check_equal "symlinked ancestor is refused" 2 "$rc" @@ -951,22 +882,22 @@ outside="$(dirname "$REPO")/darrow-decisions-outside-$$" TEMP_REPOS="$TEMP_REPOS $outside" printf 'outside\n' > "$outside" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" canonical-path --repo "$REPO" --path "$outside" 2>&1) +out=$("${DECISION[@]}" canonical-path --repo "$REPO" --path "$outside" 2>&1) rc=$? set -e check_equal "existing outside record is refused" 2 "$rc" check_contains "names containment refusal" "canonical record escapes the repository root" "$out" long_path=$(awk 'BEGIN {for (i=0; i<2000; i++) printf "x"}') set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" canonical-path --repo "$REPO" --path "$long_path" 2>&1) +out=$("${DECISION[@]}" canonical-path --repo "$REPO" --path "$long_path" 2>&1) rc=$? set -e check_equal "large path is refused" 2 "$rc" check_equal "large path refusal stays bounded" "error: --path must be at most 1000 bytes" "$out" set +e -"$SHELL_UNDER_TEST" "$SCRIPT" canonical-path --repo "$REPO" --path "$record" --dir docs/decisions >/dev/null 2>&1 +"${DECISION[@]}" canonical-path --repo "$REPO" --path "$record" --dir docs/decisions >/dev/null 2>&1 rc_dir=$? -"$SHELL_UNDER_TEST" "$SCRIPT" canonical-path --repo "$REPO" --path "$record" --limit 50 >/dev/null 2>&1 +"${DECISION[@]}" canonical-path --repo "$REPO" --path "$record" --limit 50 >/dev/null 2>&1 rc_limit=$? set -e check_equal "canonical path refuses --dir" 2 "$rc_dir" @@ -987,15 +918,15 @@ while [[ $i -le 40 ]]; do i=$((i + 1)) done write_adr ADR-0041 Accepted "Consolidated" "$relations" -"$SHELL_UNDER_TEST" "$SCRIPT" catalog rebuild --repo "$REPO" >/dev/null -out=$("$SHELL_UNDER_TEST" "$SCRIPT" validate --repo "$REPO") +"${DECISION[@]}" catalog rebuild --repo "$REPO" >/dev/null +out=$("${DECISION[@]}" validate --repo "$REPO") check_contains "validates large reciprocal fan-out" "valid: 41 ADR(s)" "$out" fresh_repo write_adr ADR-0001 Accepted "First choice" "ADR-0002" write_adr ADR-0002 Accepted "Second choice" "ADR-0001" set +e -out=$("$SHELL_UNDER_TEST" "$SCRIPT" validate --repo "$REPO" 2>&1) +out=$("${DECISION[@]}" validate --repo "$REPO" 2>&1) rc=$? set -e check_equal "cyclic graph remains invalid" 4 "$rc" @@ -1006,13 +937,13 @@ fresh_repo write_adr ADR-0001 Accepted "Decision one" write_adr ADR-0002 Accepted "Decision two" write_adr ADR-0003 Accepted "Decision three" -out=$("$SHELL_UNDER_TEST" "$SCRIPT" list --repo "$REPO" --limit 2) +out=$("${DECISION[@]}" list --repo "$REPO" --limit 2) check_contains "states output cap" "note: showing first 2 of 3 matching ADRs" "$out" check_not_contains "does not print third capped record" "ADR-0003 Accepted" "$out" i=0 while [[ $i -lt 6000 ]]; do printf 'Large context line %s with \\ paths.\n' "$i" >> "$REPO/docs/decisions/ADR-0003-test.md"; i=$((i + 1)); done -"$SHELL_UNDER_TEST" "$SCRIPT" catalog rebuild --repo "$REPO" >/dev/null -out=$("$SHELL_UNDER_TEST" "$SCRIPT" validate --repo "$REPO") +"${DECISION[@]}" catalog rebuild --repo "$REPO" >/dev/null +out=$("${DECISION[@]}" validate --repo "$REPO") check_contains "handles large ADR content" "valid: 3 ADR(s)" "$out" if [[ $FAILURES -ne 0 ]]; then diff --git a/plugins/foundation/darrow-decisions/backend/tests/fresh_install.py b/plugins/foundation/darrow-decisions/backend/tests/fresh_install.py new file mode 100644 index 00000000..9e27a7ff --- /dev/null +++ b/plugins/foundation/darrow-decisions/backend/tests/fresh_install.py @@ -0,0 +1,114 @@ +"""Execute every command from a fresh copied plugin using only runtime deps.""" + +import os +import shutil +import subprocess +import tempfile +from pathlib import Path + + +def run(args: list[str], cwd: Path, expected: int = 0) -> str: + result = subprocess.run( + args, + cwd=cwd, + capture_output=True, + encoding="utf-8", + env={**os.environ, "PYTHONUTF8": "1", "UV_NO_PROGRESS": "1"}, + ) + if result.returncode != expected: + raise AssertionError( + f"{args}: {result.returncode}\n{result.stdout}\n{result.stderr}" + ) + return result.stdout + + +def verify(work: Path) -> None: + source = Path(__file__).resolve().parents[2] + plugin = work / "copied plugin ü" + shutil.copytree( + source, + plugin, + ignore=shutil.ignore_patterns( + ".venv", + "__pycache__", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", + ".hypothesis", + ".coverage*", + ), + ) + assert not (plugin / "bin/decision").exists() + project = plugin / "backend" + repo = work / "native repository ü" + repo.mkdir() + run(["git", "init", "-q", str(repo)], work) + command = [ + "uv", + "run", + "--quiet", + "--frozen", + "--no-dev", + "--project", + str(project), + ] + run( + ["uv", "sync", "--quiet", "--frozen", "--no-dev", "--project", str(project)], + work, + ) + dependencies = run( + [ + *command, + "python", + "-c", + "import importlib.metadata as m; assert not m.requires('darrow-decisions'); " + "assert not {'pytest','mypy','ruff','coverage','hypothesis'} & {d.metadata['Name'] for d in m.distributions()}; print('runtime-only')", + ], + work, + ) + assert "runtime-only" in dependencies + entry = [*command, "darrow-decision"] + assert "Proposed -> Accepted: allowed" in run( + [*entry, "check-transition", "--from", "Proposed", "--to", "Accepted"], work + ) + assert "no ADR directory detected" in run( + [*entry, "validate", "--repo", str(repo)], work + ) + directory = repo / "docs/decisions" + directory.mkdir(parents=True) + record = directory / "ADR-0001-native.md" + record.write_bytes( + ( + "# ADR-0001: Native paths ü\n\nStatus: Accepted\nDate: 2026-09-18\n" + "Summary: Preserve native paths.\n\n## Context\n\nNative hosts.\n" + "\n## Decision\n\nUse native paths.\n\n## Consequences\n\nPortable.\n" + ).encode() + ) + verify_commands(entry, repo, record, work) + print("fresh copied plugin: all commands passed; runtime dependencies only") + + +def verify_commands(entry: list[str], repo: Path, record: Path, work: Path) -> None: + def invoke(*args: str, expected: int = 0) -> str: + return run([*entry, *args, "--repo", str(repo)], work, expected) + + assert "adr-records: 1" in invoke("inspect") + assert "total: 1" in invoke("list", "--search", "NATIVE PATHS") + assert "id: ADR-0002" in invoke("next-id", "--title", "Second native choice") + assert f"path: {record}" in invoke("canonical-path", "--path", str(record)) + assert "records: 1" in invoke("catalog", "rebuild") + assert "records: 1" in invoke("catalog", "check") + assert "valid: 1" in invoke("validate") + catalog = record.parent / "README.md" + original = catalog.read_bytes() + invoke("catalog", "rebuild") + assert catalog.read_bytes() == original + record.write_bytes(record.read_bytes().replace(b"Accepted", b"Unknown")) + invoke("catalog", "rebuild", expected=4) + assert catalog.read_bytes() == original + assert not list(record.parent.glob("README.md.tmp.*")) + + +if __name__ == "__main__": + with tempfile.TemporaryDirectory(prefix="darrow-decisions-fresh-") as temporary: + verify(Path(temporary).resolve()) diff --git a/plugins/foundation/darrow-decisions/backend/tests/test_boundaries.py b/plugins/foundation/darrow-decisions/backend/tests/test_boundaries.py new file mode 100644 index 00000000..31a307eb --- /dev/null +++ b/plugins/foundation/darrow-decisions/backend/tests/test_boundaries.py @@ -0,0 +1,231 @@ +"""Filesystem failure boundaries, native path safety, and bounded output.""" + +import os +import subprocess +from pathlib import Path +from unittest.mock import patch + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from conftest import Repository, adr +from darrow_decisions import catalog, cli, paths, queries +from darrow_decisions import inventory as sources +from darrow_decisions.errors import DecisionError, Diagnostics +from darrow_decisions.model import CONTROL +from darrow_decisions.parser import parse +from darrow_decisions.relations import validate_relations + + +def test_repository_failures(repo: Repository) -> None: + with pytest.raises(DecisionError, match="not a readable directory"): + paths.resolve_root("") + with pytest.raises(DecisionError, match="not a readable directory"): + paths.resolve_root(str(repo.root / "missing")) + with ( + patch( + "darrow_decisions.paths.git", + return_value=subprocess.CompletedProcess([], 1, b"", b""), + ), + pytest.raises(DecisionError, match="not a Git repository"), + ): + paths.resolve_root(str(repo.root)) + for stdout, message in [ + (b"invalid\n", "cannot resolve"), + (b"worktree \n", "cannot resolve"), + (b"worktree /unsafe\tpath\n", "control bytes"), + ]: + with ( + patch( + "darrow_decisions.paths.git", + return_value=subprocess.CompletedProcess([], 0, stdout, b""), + ), + pytest.raises(DecisionError, match=message), + ): + paths.resolve_root(str(repo.root)) + with ( + patch("darrow_decisions.paths.subprocess.run", side_effect=OSError("missing")), + pytest.raises(DecisionError, match="cannot run Git"), + ): + paths.git(repo.root, "status") + + +def test_path_refusals(repo: Repository, tmp_path: Path) -> None: + outside = tmp_path / "outside" + outside.mkdir() + with pytest.raises(DecisionError, match="escapes"): + paths.canonical(repo.root, str(outside), directory=True) + with pytest.raises(DecisionError, match="does not exist"): + paths.canonical(repo.root, "missing", directory=True) + path = repo.write() + with ( + patch("darrow_decisions.paths.os.access", return_value=False), + pytest.raises(DecisionError, match="not readable"), + ): + paths.canonical(repo.root, str(path)) + (repo.root / "adrs").write_text("not a directory", encoding="utf-8") + with pytest.raises(DecisionError, match="candidate is not a directory"): + paths.select_directory(repo.root) + + +def test_symlink_components(repo: Repository) -> None: + path = repo.write() + link = repo.root / "link" + try: + link.symlink_to(repo.directory, target_is_directory=True) + except OSError as error: + pytest.skip(f"native symlink privilege unavailable: {error}") + for value in (f"link/{path.name}", "link/../decisions/" + path.name): + with pytest.raises(DecisionError, match="symlinks are not allowed"): + paths.canonical(repo.root, value) + record_link = repo.directory / "ADR-0002-link.md" + record_link.symlink_to(path) + with pytest.raises(DecisionError, match="ADR symlinks are not allowed"): + sources.inventory(repo.directory) + + +def test_enumeration_and_read_failures(repo: Repository) -> None: + path = repo.write() + with patch.object(Path, "iterdir", side_effect=OSError("injected")): + with pytest.raises(DecisionError, match="cannot enumerate Markdown entries"): + sources.inventory(repo.directory) + with pytest.raises( + DecisionError, match="cannot enumerate canonical specification" + ): + queries.surface_count(repo.directory) + with patch.object(Path, "read_bytes", side_effect=OSError("injected")): + with pytest.raises(DecisionError, match="cannot parse ADR file"): + sources.inventory(repo.directory) + with pytest.raises(DecisionError, match="cannot search ADR file"): + paths.read_bytes(path, "cannot search ADR file") + with ( + patch("darrow_decisions.inventory.os.access", return_value=False), + pytest.raises(DecisionError, match="Markdown file is not readable"), + ): + sources.inventory(repo.directory) + + +def test_directory_occupied_by_record(repo: Repository) -> None: + repo.directory.mkdir(parents=True) + (repo.directory / "ADR-0001-test.md").mkdir() + with pytest.raises(DecisionError, match="unsupported file type"): + sources.inventory(repo.directory) + + +@pytest.mark.skipif( + os.name == "nt", reason="Windows rejects C0 control characters in filenames" +) +def test_control_filename(repo: Repository) -> None: + path = repo.write() + path.rename(path.with_name("ADR-0001-\tunsafe.md")) + with pytest.raises(DecisionError, match="1 ADR filename"): + sources.inventory(repo.directory) + + +def test_surfaces(repo: Repository, capsys: pytest.CaptureFixture[str]) -> None: + (repo.root / "docs/specs").mkdir(parents=True) + (repo.root / "docs/specs/spec.md").write_text("# spec", encoding="utf-8") + (repo.root / "specs").write_text("single surface", encoding="utf-8") + (repo.root / "AGENTS.md").write_text("# policy", encoding="utf-8") + queries.inspect(repo.root, None) + out = capsys.readouterr().out + assert f"{repo.root / 'docs/specs'} (1 Markdown files)" in out + assert f" - {repo.root / 'AGENTS.md'}" in out + with ( + patch("darrow_decisions.queries.os.access", return_value=False), + pytest.raises( + DecisionError, match="canonical specification surface is not readable" + ), + ): + queries.surfaces(repo.root, ("docs/specs",), "specification") + + +def test_exhaustion_and_late_collision(repo: Repository) -> None: + path = repo.write("ADR-999999999999999999") + with pytest.raises(DecisionError, match="space is exhausted"): + queries.next_id(repo.directory, "") + path.unlink() + repo.write() + target = repo.directory / "ADR-0002-title.md" + + def occupy(_: str) -> str: + target.write_bytes(b"concurrent change") + return "title" + + with ( + patch("darrow_decisions.queries.slugify", side_effect=occupy), + pytest.raises(DecisionError, match="path is already occupied"), + ): + queries.next_id(repo.directory, "Title") + assert target.read_bytes() == b"concurrent change" + + +def test_empty_directory_and_entrypoint( + repo: Repository, monkeypatch: pytest.MonkeyPatch +) -> None: + repo.directory.mkdir(parents=True) + assert cli.main(["validate", "--repo", str(repo.root)]) == 0 + monkeypatch.setattr( + "sys.argv", + ["darrow-decision", "check-transition", "--from", "Draft", "--to", "Accepted"], + ) + with pytest.raises(SystemExit) as caught: + cli.entrypoint() + assert caught.value.code == 2 + + +def test_missing_record_identifier_is_not_a_graph_vertex() -> None: + record = parse(adr().replace("# ADR-0001: Use SQLite", "# Unknown")).record( + Path("ADR-0001.md") + ) + errors = Diagnostics() + validate_relations([record], errors) + assert errors.count == 0 + + +@given( + st.lists( + st.sampled_from(["part", "space name", "é", ".", ".."]), min_size=1, max_size=10 + ) +) +@settings(max_examples=50, derandomize=True) +def test_normalized_paths_never_escape_root(parts: list[str]) -> None: + root = Path.cwd().resolve() + value = root.joinpath(*parts) + if value.resolve().is_relative_to(root): + assert paths.contained(value, root, "record") == value.resolve() + else: + with pytest.raises(DecisionError, match="escapes"): + paths.contained(value, root, "record") + + +@given(st.integers(min_value=0, max_value=127)) +@settings(derandomize=True) +def test_control_classifier_matches_byte_contract(value: int) -> None: + assert bool(CONTROL.search(chr(value))) == (value < 32 or value == 127) + + +def test_catalog_membership_safety(repo: Repository) -> None: + repo.write() + catalog.rebuild(repo.root, repo.directory) + repo.commit() + (repo.directory / "ADR-0002-test.md").mkdir() + assert catalog.fresh(repo.root, repo.directory)[1] == "stale" + + +@given( + st.text(alphabet="abcXYZ0123 -_", max_size=100), + st.sampled_from([chr(value) for value in (*range(32), 127)]), +) +@settings(max_examples=60, derandomize=True) +def test_generated_unsafe_input_is_refused_without_echo( + prefix: str, control: str +) -> None: + import contextlib + import io + + output = io.StringIO() + with contextlib.redirect_stderr(output): + assert cli.main(["list", "--search", prefix + control]) == 2 + assert output.getvalue() == "error: --search contains unsupported control bytes\n" diff --git a/plugins/foundation/darrow-decisions/backend/tests/test_catalog.py b/plugins/foundation/darrow-decisions/backend/tests/test_catalog.py new file mode 100644 index 00000000..6192c69e --- /dev/null +++ b/plugins/foundation/darrow-decisions/backend/tests/test_catalog.py @@ -0,0 +1,220 @@ +"""Derived-catalog round trips, trust boundaries, and atomic failure behavior.""" + +import os +import subprocess +from dataclasses import replace +from pathlib import Path +from unittest.mock import patch + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from conftest import Repository +from darrow_decisions import catalog, queries +from darrow_decisions.catalog_format import ( + CatalogRow, + checksum, + parse_relationships, + render, + target, + untarget, +) +from darrow_decisions.catalog_parse import parse_catalog +from darrow_decisions.cli import main +from darrow_decisions.errors import DecisionError +from darrow_decisions.model import Record + + +@given( + st.text(alphabet="abc é|\\[]<>&%?#()", min_size=1, max_size=50).filter( + lambda text: bool(text.strip()) + ) +) +@settings(max_examples=60, derandomize=True) +def test_catalog_metadata_roundtrip(value: str) -> None: + value = value.strip() + record = Record( + "ADR-0001", + value, + "Accepted", + "2026-09-18", + value, + "", + "", + value, + Path("ADR-0001-safe.md"), + ) + row = CatalogRow(record, "a" * 40, 0, 0) + crc, length = checksum(row.signature()) + row = replace(row, crc=crc, size=length) + restored = parse_catalog(render([row]), Path(".")) + assert restored == [row] + assert restored[0].valid_checksum() + assert untarget(target(value)) == value + + +@pytest.mark.parametrize( + ("payload", "expected"), [(b"", 4294967295), (b"hello\n", 3015617425)] +) +def test_posix_checksum_vectors(payload: bytes, expected: int) -> None: + assert checksum(payload) == (expected, len(payload)) + + +def test_metadata_fast_path_and_body_search(repo: Repository) -> None: + repo.write(body="A body-only quasar marker.") + repo.write("ADR-0002", body="Unrelated content.") + catalog.rebuild(repo.root, repo.directory) + repo.commit() + with patch( + "darrow_decisions.inventory.parse", side_effect=AssertionError("body parsed") + ): + records, reason = catalog.fresh(repo.root, repo.directory) + assert reason == "" + assert len(records) == 2 + assert len(queries.list_inventory(repo.root, repo.directory, "")) == 2 + with patch( + "darrow_decisions.inventory.parse", + wraps=__import__("darrow_decisions.parser", fromlist=["parse"]).parse, + ) as parser: + assert main(["list", "--repo", str(repo.root), "--search", "quasar"]) == 0 + assert parser.call_count == 2 + + +@pytest.mark.parametrize("index_flag", ["--assume-unchanged", "--skip-worktree"]) +def test_index_flags_cannot_hide_catalog_mutation( + repo: Repository, index_flag: str +) -> None: + repo.write() + catalog.rebuild(repo.root, repo.directory) + repo.commit() + repo.git("update-index", index_flag, "docs/decisions/README.md") + path = repo.directory / "README.md" + path.write_bytes(path.read_bytes() + b"\n") + assert catalog.fresh(repo.root, repo.directory)[1] == "stale" + + +def test_filtered_git_blobs_cannot_hide_source_mutation(repo: Repository) -> None: + path = repo.write() + (repo.root / ".gitattributes").write_text( + "docs/decisions/*.md -text\n", encoding="utf-8" + ) + catalog.rebuild(repo.root, repo.directory) + repo.commit() + path.write_bytes(path.read_bytes().replace(b"Accepted", b"Rejected")) + repo.git("update-index", "--assume-unchanged", "docs/decisions/" + path.name) + assert catalog.fresh(repo.root, repo.directory)[1] == "stale" + + +@pytest.mark.parametrize( + "mutation", ["prose", "row", "source", "membership", "invalid-source"] +) +def test_fallback_never_hides_bad_inputs(repo: Repository, mutation: str) -> None: + path = repo.write() + catalog_path = repo.directory / "README.md" + catalog.rebuild(repo.root, repo.directory) + if mutation == "membership": + repo.write("ADR-0002") + elif mutation == "invalid-source": + path.write_bytes(b"# malformed\n") + else: + data = catalog_path.read_bytes() + data = { + "prose": data + b"injected\n", + "row": data.replace(b"Use SQLite.", b"Use forged."), + "source": data.replace(b"<!-- darrow-source:", b"<!-- invalid:"), + }[mutation] + catalog_path.write_bytes(data) + repo.commit() + assert catalog.fresh(repo.root, repo.directory)[1] in {"stale", "malformed"} + expected = 4 if mutation == "invalid-source" else 0 + assert main(["list", "--repo", str(repo.root)]) == expected + + +def test_unreadable_and_unsupported_catalog(repo: Repository) -> None: + repo.write() + path = repo.directory / "README.md" + path.mkdir() + with pytest.raises(DecisionError, match="not a regular file"): + catalog.rebuild(repo.root, repo.directory) + assert catalog.fresh(repo.root, repo.directory)[1] == "malformed" + path.rmdir() + path.write_text("# Human guide\n", encoding="utf-8") + with pytest.raises(DecisionError, match="refusing to replace"): + catalog.rebuild(repo.root, repo.directory) + assert path.read_text(encoding="utf-8") == "# Human guide\n" + with patch("darrow_decisions.catalog.os.access", return_value=False): + assert catalog.fresh(repo.root, repo.directory)[1] == "unreadable" + with pytest.raises(DecisionError, match="not readable"): + catalog.rebuild(repo.root, repo.directory) + + +def test_atomic_failure_preserves_original_and_cleans_temp(repo: Repository) -> None: + repo.write() + catalog.rebuild(repo.root, repo.directory) + path = repo.directory / "README.md" + original = path.read_bytes() + with ( + patch("darrow_decisions.catalog.os.replace", side_effect=OSError("injected")), + pytest.raises(DecisionError, match="cannot replace ADR catalog"), + ): + catalog.atomic_write(path, b"replacement") + assert path.read_bytes() == original + assert list(repo.directory.glob("README.md.tmp.*")) == [] + with ( + patch( + "darrow_decisions.catalog.tempfile.NamedTemporaryFile", + side_effect=OSError("injected"), + ), + pytest.raises(DecisionError, match="cannot create an atomic"), + ): + catalog.atomic_write(path, b"replacement") + + +def test_fingerprint_failure(repo: Repository) -> None: + path = repo.write() + with ( + patch( + "darrow_decisions.catalog.git", + return_value=subprocess.CompletedProcess([], 1, b"", b""), + ), + pytest.raises(DecisionError, match="cannot fingerprint"), + ): + catalog.fingerprint(repo.root, path) + + +@pytest.mark.parametrize( + "value", + [ + "Wrong: ADR-0001", + "Supersedes: ", + "Revisit when: ", + "Supersedes: ADR-0001; Bad: x", + ], +) +def test_invalid_catalog_relationships(value: str) -> None: + with pytest.raises(ValueError, match="invalid relationships"): + parse_relationships(value) + + +@pytest.mark.parametrize( + "value", + [ + "Supersedes: ADR-0001", + "Superseded by: ADR-0002", + "Supersedes: ADR-0001; Superseded by: ADR-0002; Revisit when: trigger", + ], +) +def test_catalog_relationships(value: str) -> None: + assert any(parse_relationships(value)) + + +@pytest.mark.skipif( + os.name == "nt", reason="Backslash is a native path separator on Windows" +) +def test_posix_filename_backslash(repo: Repository) -> None: + path = repo.write() + path.rename(path.with_name("ADR-0001-back\\slash-é.md")) + catalog.rebuild(repo.root, repo.directory) + repo.commit() + assert catalog.fresh(repo.root, repo.directory)[1] == "" diff --git a/plugins/foundation/darrow-decisions/backend/tests/test_cli.py b/plugins/foundation/darrow-decisions/backend/tests/test_cli.py new file mode 100644 index 00000000..537eb761 --- /dev/null +++ b/plugins/foundation/darrow-decisions/backend/tests/test_cli.py @@ -0,0 +1,183 @@ +"""Commands, refusal codes, canonical scope, and read-only behavior.""" + +import itertools +from pathlib import Path + +import pytest + +from conftest import Repository +from darrow_decisions.arguments import USAGE +from darrow_decisions.cli import main +from darrow_decisions.model import STATUSES + + +def call(repo: Repository, *args: str) -> int: + return main([*args, "--repo", str(repo.root)]) + + +def test_empty_commands(repo: Repository, capsys: pytest.CaptureFixture[str]) -> None: + assert call(repo, "inspect") == 0 + assert "adr-records: 0" in capsys.readouterr().out + assert call(repo, "list") == 0 + assert ( + capsys.readouterr().out + == "total: 0 (status=all, search=(none), related-to=(none))\n" + ) + assert call(repo, "validate") == 0 + assert capsys.readouterr().out == "valid: 0 ADR(s); no ADR directory detected\n" + for args in (("next-id",), ("catalog", "rebuild"), ("catalog", "check")): + assert call(repo, *args) == 2 + assert "no ADR directory detected" in capsys.readouterr().err + + +def test_catalog_lifecycle( + repo: Repository, capsys: pytest.CaptureFixture[str] +) -> None: + path = repo.write() + assert call(repo, "validate") == 4 + assert "catalog is missing" in capsys.readouterr().err + assert call(repo, "catalog", "check") == 4 + assert call(repo, "catalog", "rebuild") == 0 + catalog = repo.directory / "README.md" + before = catalog.read_bytes() + assert call(repo, "catalog", "check") == 0 + assert call(repo, "validate") == 0 + assert call(repo, "catalog", "rebuild") == 0 + assert catalog.read_bytes() == before + repo.commit() + capsys.readouterr() + assert call(repo, "list", "--status", "Accepted") == 0 + output = capsys.readouterr() + assert output.err == "" + assert f"ADR-0001 Accepted architecture — Use SQLite — {path}" in output.out + assert repo.git("status", "--porcelain") == "" + path.write_bytes(path.read_bytes() + b"\nbody-only QUASAR\n") + assert call(repo, "list", "--search", "quasar") == 0 + output = capsys.readouterr() + assert "stale; using full scan" in output.err + assert "total: 1" in output.out + assert call(repo, "catalog", "check") == 4 + assert "catalog is stale" in capsys.readouterr().err + + +@pytest.mark.parametrize(("old", "new"), itertools.product(STATUSES, repeat=2)) +def test_all_transitions( + old: str, new: str, capsys: pytest.CaptureFixture[str] +) -> None: + valid = (old, new) in { + ("Proposed", "Accepted"), + ("Proposed", "Rejected"), + ("Accepted", "Deprecated"), + ("Accepted", "Superseded"), + } + assert main(["check-transition", "--from", old, "--to", new]) == (0 if valid else 3) + out = capsys.readouterr() + assert f"{old} -> {new}" in out.out + out.err + + +@pytest.mark.parametrize( + "args", + [ + [], + ["unknown"], + ["catalog"], + ["catalog", "unknown"], + ["inspect", "--repo"], + ["inspect", "--status", "Accepted"], + ["list", "--title", "name"], + ["canonical-path"], + ["canonical-path", "--path", "x", "--dir", "docs"], + ["check-transition", "--from", "Accepted"], + ["check-transition", "--from", "", "--to", "Accepted"], + ["inspect", "--unknown", "x"], + ], +) +def test_usage(args: list[str], capsys: pytest.CaptureFixture[str]) -> None: + assert main(args) == 2 + assert capsys.readouterr().err == USAGE + "\n" + + +@pytest.mark.parametrize( + ("args", "message"), + [ + (["list", "--status", "Draft"], "unsupported decision status: Draft"), + (["list", "--related-to", "12"], "invalid related decision identifier"), + (["list", "--related-to", "ADR-" + "1" * 19], "18-digit limit"), + (["list", "--limit", "0"], "--limit must be an integer"), + (["list", "--limit", "201"], "--limit must be an integer"), + (["list", "--limit", "1000"], "--limit must be an integer"), + (["list", "--limit", "x"], "--limit must be an integer"), + (["list", "--search", "é" * 101], "--search must be at most 200 bytes"), + (["next-id", "--title", "é" * 101], "--title must be at most 200 bytes"), + (["next-id", "--title", "日本語"], "must contain an ASCII letter or digit"), + (["canonical-path", "--path", "x" * 1001], "--path must be at most 1000 bytes"), + ], +) +def test_invalid_options( + repo: Repository, args: list[str], message: str, capsys: pytest.CaptureFixture[str] +) -> None: + repo.directory.mkdir(parents=True) + assert call(repo, *args) == 2 + assert message in capsys.readouterr().err + + +@pytest.mark.parametrize("value", ["\n", "\t", "\r", "\x1b", "\x7f"]) +def test_control_input( + repo: Repository, value: str, capsys: pytest.CaptureFixture[str] +) -> None: + assert call(repo, "list", "--search", "secret" + value) == 2 + assert ( + capsys.readouterr().err + == "error: --search contains unsupported control bytes\n" + ) + + +def test_filters_and_numbering( + repo: Repository, capsys: pytest.CaptureFixture[str] +) -> None: + repo.write("ADR-0001", status="Superseded", metadata="Superseded by: ADR-0002\n") + repo.write("ADR-0002", metadata="Supersedes: ADR-0001\n") + repo.write("ADR-0003", status="Proposed") + assert call(repo, "list", "--related-to", "ADR-0001") == 0 + assert "supersedes: ADR-0001" in capsys.readouterr().out + assert call(repo, "list", "--limit", "1") == 0 + assert "showing first 1 of 3" in capsys.readouterr().out + assert call(repo, "list", "--status", "Rejected") == 0 + assert "total: 0" in capsys.readouterr().out + assert call(repo, "list", "--search", "absent") == 0 + assert "total: 0" in capsys.readouterr().out + assert call(repo, "next-id", "--title", "Use SQLITE & files") == 0 + assert ( + f"id: ADR-0004\npath: {repo.directory / 'ADR-0004-use-sqlite-files.md'}\n" + == capsys.readouterr().out + ) + assert call(repo, "next-id") == 0 + assert capsys.readouterr().out == "id: ADR-0004\n" + + +def test_canonical_scope(repo: Repository, capsys: pytest.CaptureFixture[str]) -> None: + path = repo.write() + assert ( + call(repo, "canonical-path", "--path", "docs/../docs/decisions/" + path.name) + == 0 + ) + assert capsys.readouterr().out == f"path: {path}\n" + assert call(repo, "canonical-path", "--path", str(path)) == 0 + assert call(repo, "canonical-path", "--path", "missing") == 2 + assert call(repo, "canonical-path", "--path", "") == 2 + capsys.readouterr() + (repo.root / "adrs").mkdir() + assert call(repo, "inspect") == 3 + assert "multiple ADR directories found" in capsys.readouterr().err + assert call(repo, "inspect", "--dir", "docs/decisions") == 0 + + +def test_primary_worktree( + repo: Repository, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + repo.write() + repo.commit() + linked = tmp_path / "linked" + repo.git("worktree", "add", "-qb", "linked", str(linked)) + assert main(["inspect", "--repo", str(linked)]) == 0 + assert f"root: {repo.root}" in capsys.readouterr().out diff --git a/plugins/foundation/darrow-decisions/backend/tests/test_contract.py b/plugins/foundation/darrow-decisions/backend/tests/test_contract.py new file mode 100644 index 00000000..e30f4d0a --- /dev/null +++ b/plugins/foundation/darrow-decisions/backend/tests/test_contract.py @@ -0,0 +1,11 @@ +"""Public CLI acceptance before replacing the Bash implementation.""" + +import subprocess +from pathlib import Path + +from darrow_decisions.cli import main + + +def test_empty_repository(tmp_path: Path, capsys: object) -> None: + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + assert main(["validate", "--repo", str(tmp_path)]) == 0 diff --git a/plugins/foundation/darrow-decisions/backend/tests/test_malformed_catalog.py b/plugins/foundation/darrow-decisions/backend/tests/test_malformed_catalog.py new file mode 100644 index 00000000..9fa3d3a5 --- /dev/null +++ b/plugins/foundation/darrow-decisions/backend/tests/test_malformed_catalog.py @@ -0,0 +1,135 @@ +"""Malformed catalog structures must fail closed at explicit validation.""" + +import io +from pathlib import Path +from unittest.mock import patch + +import pytest + +from conftest import adr +from darrow_decisions import cli +from darrow_decisions.catalog_format import ( + TABLE, + CatalogRow, + parse_record, + parse_source, + render, +) +from darrow_decisions.catalog_parse import parse_catalog +from darrow_decisions.parser import parse + + +@pytest.mark.parametrize( + "row", + [ + "| too | few |", + "| [ADR-0001: title](ADR-0001.md) | Accepted | 2026-01-01 | summary | None | injected", + "| [ADR-0001: title](ADR-0001.md) | Proposed | 2026-01-01 | summary | None |", + "| [ADR-0001: title](../ADR-0001.md) | Accepted | 2026-01-01 | summary | None |", + "| [ADR-0001: title](ADR-0001-%.md) | Accepted | 2026-01-01 | summary | None |", + ], +) +def test_malformed_visible_rows(row: str) -> None: + with pytest.raises(ValueError): + parse_record(row, "Accepted", Path(".")) + + +def test_bad_source_marker() -> None: + record = parse(adr()).record(Path("ADR-0001.md")) + with pytest.raises(ValueError, match="invalid source marker"): + parse_source("<!-- darrow-source: invalid -->", record) + + +@pytest.mark.parametrize( + "mutation", + [ + "duplicate-header", + "missing-header", + "missing-section", + "missing-source", + "duplicate-id", + "duplicate-name", + "missing-introduction", + ], +) +def test_malformed_catalog_structure(mutation: str) -> None: + first = parse(adr()).record(Path("ADR-0001.md")) + second = parse(adr("ADR-0002")).record(Path("ADR-0002.md")) + data = render( + [CatalogRow(first, "a" * 40, 1, 1), CatalogRow(second, "b" * 40, 1, 1)] + ) + table = TABLE.encode() + header = table.splitlines(keepends=True)[1] + malformed = { + "duplicate-header": data.replace(header, header * 2, 1), + "missing-header": data.replace(table, b"", 2), + "missing-section": data.replace(b"## Proposed\n", b""), + "missing-source": b"\n".join( + line + for line in data.split(b"\n") + if not line.startswith(b"<!-- darrow-source:") + ), + "duplicate-id": data.replace(b"ADR-0002:", b"ADR-0001:"), + "duplicate-name": data.replace(b"ADR-0002.md", b"ADR-0001.md"), + "missing-introduction": data.replace(b"# ADR Catalog\n", b""), + } + with pytest.raises(ValueError): + parse_catalog(malformed[mutation], Path(".")) + + +def test_entrypoint_emits_utf8_without_platform_newline_conversion() -> None: + stream = io.TextIOWrapper(io.BytesIO(), encoding="ascii") + with ( + patch("sys.stdout", stream), + patch("sys.stderr", stream), + patch( + "sys.argv", + [ + "darrow-decision", + "check-transition", + "--from", + "Proposed", + "--to", + "Accepted", + ], + ), + pytest.raises(SystemExit, match="0"), + ): + cli.entrypoint() + assert stream.encoding == "utf-8" + stream.flush() + assert stream.buffer.getvalue() == b"Proposed -> Accepted: allowed\n" + + +def test_entrypoint_supports_redirected_string_streams() -> None: + output = io.StringIO() + with ( + patch("sys.stdout", output), + patch("sys.stderr", output), + patch( + "sys.argv", + [ + "darrow-decision", + "check-transition", + "--from", + "Proposed", + "--to", + "Accepted", + ], + ), + pytest.raises(SystemExit, match="0"), + ): + cli.entrypoint() + assert output.getvalue() == "Proposed -> Accepted: allowed\n" + + +def test_filesystem_race_is_a_refusal(capsys: pytest.CaptureFixture[str]) -> None: + with patch( + "darrow_decisions.cli.run", + side_effect=PermissionError("record became unreadable"), + ): + assert cli.main(["inspect"]) == 2 + assert ( + capsys.readouterr().err + == "error: decision filesystem operation failed: record became unreadable\n" + ) diff --git a/plugins/foundation/darrow-decisions/backend/tests/test_sources.py b/plugins/foundation/darrow-decisions/backend/tests/test_sources.py new file mode 100644 index 00000000..48428f18 --- /dev/null +++ b/plugins/foundation/darrow-decisions/backend/tests/test_sources.py @@ -0,0 +1,236 @@ +"""Malformed metadata, fenced Markdown, and supersession graph refusals.""" + +from dataclasses import replace +from pathlib import Path + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from conftest import Repository, adr +from darrow_decisions.errors import DecisionError, Diagnostics +from darrow_decisions.inventory import inventory +from darrow_decisions.model import Record +from darrow_decisions.parser import parse +from darrow_decisions.relations import validate_relations +from darrow_decisions.validation import valid_date, validate_record + + +def problems(text: str, name: str = "ADR-0001-test.md") -> list[str]: + parsed = parse(text) + errors = Diagnostics() + validate_record(parsed, parsed.record(Path(name)), errors) + return errors.messages + + +@pytest.mark.parametrize( + ("before", "after", "message"), + [ + ("# ADR-0001: Use SQLite", "# ADR-0001:", "ADR title is empty"), + ( + "# ADR-0001: Use SQLite", + "# ADR-0001: " + "é" * 121, + "title exceeds 240 bytes", + ), + ("ADR-0001", "ADR-1", "unsupported ADR identifier"), + ("ADR-0001", "ADR-" + "1" * 19, "18-digit limit"), + ("Status: Accepted", "Status: Draft", "unsupported decision status: Draft"), + ("Status: Accepted", "Status: " + "a" * 33, "decision status exceeds 32"), + ("Status: Accepted", "", "Status field; found 0"), + ( + "Status: Accepted", + "Status: Accepted\nStatus: Accepted", + "Status field; found 2", + ), + ("Date: 2026-07-19", "Date: 2026-02-30", "not a valid ISO calendar date"), + ("Date: 2026-07-19", "Date: " + "x" * 33, "Date exceeds 32"), + ("Date: 2026-07-19", "", "Date field; found 0"), + ("Summary: Use SQLite.", "Summary: ", "Summary field is blank"), + ("Summary: Use SQLite.", "Summary: " + "é" * 251, "Summary exceeds 500"), + ("Summary: Use SQLite.", "", "Summary field; found 0"), + ("## Context", "## Other", "Context section must occur once"), + ("The choice is explicit.", "", "Decision section must occur once"), + ("Consequences.", "", "Consequences section must occur once"), + ( + "## Consequences", + "## Consequences\n\nextra\n\n## Consequences", + "Consequences section must occur once", + ), + ("## Context", "```\n## Context", "Markdown fence is not closed"), + ], +) +def test_malformed_sources(before: str, after: str, message: str) -> None: + assert any(message in error for error in problems(adr().replace(before, after))) + + +@pytest.mark.parametrize("key", ["Supersedes", "Superseded by", "Revisit when"]) +def test_optional_metadata(key: str) -> None: + assert any( + f"{key} field is blank" in error + for error in problems(adr(metadata=f"{key}:\n")) + ) + assert any( + f"duplicate {key} fields" in error + for error in problems(adr(metadata=f"{key}: x\n{key}: y\n")) + ) + assert any( + f"{key} exceeds" in error + for error in problems(adr(metadata=f"{key}: " + "x" * 1001 + "\n")) + ) + + +def test_heading_and_sections() -> None: + assert any( + "filename does not carry" in error for error in problems(adr(), "notes.md") + ) + assert any("disagrees" in error for error in problems(adr(), "ADR-0002-test.md")) + assert any("heading has no identifier" in error for error in problems("nothing")) + assert any( + "found 2" in error for error in problems(adr() + "\n# ADR-0002: Duplicate\n") + ) + text = adr(body=" ~~~lang\n# ADR-9999: not metadata\n```\n~~~\n### Nested\ntext") + assert problems("\ufeff" + text) == [] + assert ( + problems( + adr() + .replace("# ADR-0001:", " # ADR-0001:") + .replace("## Context", "## Context ##") + ) + == [] + ) + assert problems(adr().replace("Context.", "### Empty subsection")) + + +@pytest.mark.parametrize( + "date", ["2000-02-29", "2024-02-29", "9999-12-31", "0001-01-01"] +) +def test_calendar_dates(date: str) -> None: + assert valid_date(date) + + +@pytest.mark.parametrize( + "date", ["1900-02-29", "0000-01-01", "2026-1-01", "2026-13-01", "x"] +) +def test_invalid_calendar_dates(date: str) -> None: + assert not valid_date(date) + + +def test_bounded_diagnostics(repo: Repository) -> None: + for index in range(50): + repo.write(f"ADR-{index:04d}", status="Unknown") + with pytest.raises(DecisionError) as caught: + inventory(repo.directory) + assert caught.value.code == 4 + assert len(str(caught.value).splitlines()) == 41 + assert str(caught.value).endswith("10 additional diagnostics omitted") + + +def record( + identifier: str, + status: str = "Accepted", + supersedes: str = "", + superseded_by: str = "", +) -> Record: + return replace( + parse(adr(identifier, status=status)).record(Path(f"{identifier}.md")), + supersedes=supersedes, + superseded_by=superseded_by, + ) + + +@pytest.mark.parametrize( + ("records", "message"), + [ + ([record("ADR-0001"), record("ADR-0001")], "duplicate ADR identifier"), + ([record("ADR-0001", supersedes="bad")], "invalid Supersedes identifier"), + ( + [record("ADR-0001", supersedes="ADR-0002, ADR-0002")], + "duplicate Supersedes target", + ), + ([record("ADR-0001", supersedes="ADR-0001")], "cannot relate to itself"), + ([record("ADR-0001", supersedes="ADR-0002")], "target does not exist"), + ( + [record("ADR-0001", supersedes="ADR-0002"), record("ADR-0002")], + "must have status Superseded", + ), + ( + [ + record("ADR-0001", supersedes="ADR-0002"), + record("ADR-0002", "Superseded", superseded_by="ADR-0003"), + ], + "does not reciprocate", + ), + ( + [record("ADR-0001", "Proposed", supersedes="ADR-0002")], + "allowed only on an Accepted", + ), + ([record("ADR-0001", superseded_by="ADR-0002")], "requires status Superseded"), + ([record("ADR-0001", "Superseded")], "requires Superseded by"), + ( + [ + record("ADR-0001", "Superseded", superseded_by="ADR-0002"), + record("ADR-0002", "Proposed"), + ], + "replacement ADR-0002 must have status", + ), + ([record("ADR-0001", supersedes=",".join(["x"] * 51))], "more than 50 targets"), + ], +) +def test_graph_refusals(records: list[Record], message: str) -> None: + errors = Diagnostics() + validate_relations(records, errors) + assert any(message in error for error in errors.messages) + + +@given(st.integers(min_value=2, max_value=80), st.booleans()) +@settings(max_examples=30, derandomize=True) +def test_generated_supersession_chains(length: int, cycle: bool) -> None: + records = [] + for index in range(length): + previous = ( + f"ADR-{index:04d}" if index else (f"ADR-{length:04d}" if cycle else "") + ) + following = ( + f"ADR-{index + 2:04d}" + if index < length - 1 + else ("ADR-0001" if cycle else "") + ) + records.append( + record( + f"ADR-{index + 1:04d}", + "Superseded" if following else "Accepted", + supersedes=previous, + superseded_by=following, + ) + ) + errors = Diagnostics() + validate_relations(records, errors) + assert bool(errors.count) == cycle + if cycle: + assert "cycle detected" in errors.messages[-1] + + +@given( + st.text(alphabet="abcXYZé|&[]<>\\ ", min_size=1, max_size=60).filter( + lambda text: bool(text.strip()) + ) +) +@settings(max_examples=50, derandomize=True) +def test_frontmatter_roundtrip(title: str) -> None: + title = title.strip(" \t") + parsed = parse(adr(title=title)) + result = parsed.record(Path("ADR-0001-test.md")) + assert result.title == title + assert result.summary == title + "." + assert problems(adr(title=title)) == [] + + +@given(st.text(alphabet="ABC xyz012#\n\t`~:", max_size=100)) +@settings(max_examples=60, derandomize=True) +def test_fenced_metadata_cannot_override_authority(payload: str) -> None: + # A long matching fence cannot be closed by any generated run. + fence = "~" * 101 + text = adr(body=f"{fence}\nStatus: Rejected\n{payload}\n{fence}") + parsed = parse(text) + assert parsed.record(Path("ADR-0001-test.md")).status == "Accepted" + assert parsed.counts["Status"] == 1 diff --git a/plugins/foundation/darrow-decisions/backend/uv.lock b/plugins/foundation/darrow-decisions/backend/uv.lock new file mode 100644 index 00000000..bd0c944a --- /dev/null +++ b/plugins/foundation/darrow-decisions/backend/uv.lock @@ -0,0 +1,462 @@ +version = 1 +revision = 3 +requires-python = ">=3.10, <3.14" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/2d/c738872f477f5687152acae68635790387425d407ae37dd3d3a8a6692307/coverage-7.16.1.tar.gz", hash = "sha256:f83981779bcf9dfa06fa0a8d4cb43e0faec1706328ce07aa3e7b665b4ac0f210", size = 969651, upload-time = "2026-09-13T19:12:21.422Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/62/a8b4dd53308a4b1571e6bdd77ee562c9b1c1742db9886c0aef2f69463067/coverage-7.16.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4f12a9e27ca7b65e40a8475d27899b2d45064d9020e6a89148939e01987b5853", size = 223178, upload-time = "2026-09-13T19:08:21.805Z" }, + { url = "https://files.pythonhosted.org/packages/5e/7a/b9325b8d486a5c05fd4a9eb1cd9c9f697c361ddeda0e73567d5c2d8959f6/coverage-7.16.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f590d46c30d9e4c1fda3efefe5443f4c2f6a4192c5ca2ba403653e9ebadf097", size = 223701, upload-time = "2026-09-13T19:08:23.391Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ee/3ab3ac6e9e7165a623695b73a83d1bfbdf5069681a68a012cb5abb096060/coverage-7.16.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3df82f0a3cef4e1bcfc799436056f0b978dda319d0bfd4460c6e479b2802d98a", size = 250434, upload-time = "2026-09-13T19:08:24.948Z" }, + { url = "https://files.pythonhosted.org/packages/b6/a9/31ffc231e9879876f611cf1ac7622cf092d76762c07ca64dce47b29a200b/coverage-7.16.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb462d59146656e278d1e8ed913ce374d0ba68a4e081acde1867f6d3377fc881", size = 252264, upload-time = "2026-09-13T19:08:26.734Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f4/4fb4f6b16de07dbae3d3a111ae28d32ad657ce6ff4d364c49c10962daad5/coverage-7.16.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7db888dd0a1df1a653cae7f99d4047430d2187a3626eda51bc847b0fd6b9b43", size = 254127, upload-time = "2026-09-13T19:08:28.254Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3d/048e88eb610f44b7542dac10fa45bb2ffad5fbfcdab0f03289a843da934b/coverage-7.16.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:38a7e16f061504ac2b45370bf5bf97e8250d8d3f25e37385bae884978166554b", size = 256043, upload-time = "2026-09-13T19:08:30.019Z" }, + { url = "https://files.pythonhosted.org/packages/97/59/d27b113c80b04c7fc64c69457132648e1d287b83a1de41189a504b50b52b/coverage-7.16.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2b26b55b18e1a53e1a159dd743728c4ddbbef28ba19000a91aaff5ce023197ec", size = 251092, upload-time = "2026-09-13T19:08:31.57Z" }, + { url = "https://files.pythonhosted.org/packages/45/42/b7eafaceff08dc1dcbfc924fa9233031fd00c8b8287cc4a1a0dcdedcb0f9/coverage-7.16.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fdb2f528b50953e29d22033b3256c396a700193c6e45b2490222ef9c333cbbf9", size = 252170, upload-time = "2026-09-13T19:08:33.191Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8f/57dcc5360aa6d8d24370c12fe855f6ebf5fbf2718c154fb9fdec7161de07/coverage-7.16.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3284754371dc78592aa3ae4d661d30ee20e02b2b6b0de3590a181a936d0d3b38", size = 250173, upload-time = "2026-09-13T19:08:34.75Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/379a8c7ac0f3063769dc43fd2475e9fc9b2722cb88eabe71f4d862660c93/coverage-7.16.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:802d1246c540e07486d4ee1adfa19a797e4b33529ffc371dc140644e8f27da0a", size = 253986, upload-time = "2026-09-13T19:08:36.737Z" }, + { url = "https://files.pythonhosted.org/packages/45/25/70218d2a6077730843b75ee2525fa64a750283d71cc4c063dc0b75ba6979/coverage-7.16.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:6fc735d6fe6d57f803e7ba021be4dde48e43e6aff94e6954350555d5332b0594", size = 250444, upload-time = "2026-09-13T19:08:38.434Z" }, + { url = "https://files.pythonhosted.org/packages/58/ac/6c41c441baa6c8a1fed5615e4e1e7d0ca649b33b472ee28591a92d70a1ff/coverage-7.16.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ed5ade1bb18f62edace1bd198c66f9d4c75a8385d5fd24e87d917ea1a5958773", size = 251046, upload-time = "2026-09-13T19:08:40.133Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/01166d6f8aa42acd148b4c2c88173d60eefdd0270ea2bcbba79be35bc193/coverage-7.16.1-cp310-cp310-win32.whl", hash = "sha256:0c309096926b119543dc16438a11ef4c80783d2f4e59ff94f7f462651a944cdc", size = 225242, upload-time = "2026-09-13T19:08:41.672Z" }, + { url = "https://files.pythonhosted.org/packages/d2/28/a76060478919becf0fbea2df2053a6e1a7195f11edf8dfd51c58c1636b43/coverage-7.16.1-cp310-cp310-win_amd64.whl", hash = "sha256:7562f8067ed9360e8b9739e5703403a7686dd1b36bf0f89fc538047c54cdea90", size = 225868, upload-time = "2026-09-13T19:08:43.22Z" }, + { url = "https://files.pythonhosted.org/packages/af/3a/d09495dfd5191b5593852bbe2f037afae768157443378d066d9ecea69a49/coverage-7.16.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:72e013665e25cf9d44779f01f340af26319756f9a76822b7c94ce6b1d93813da", size = 223307, upload-time = "2026-09-13T19:08:44.914Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/310196a80258e28ebf6e5aa814a5a6ffbb18c03cf3c0c0c04191ed893e88/coverage-7.16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cb05c0ff98b56ba6969adf35556bc43bcb8d094df8bc9cb403acff53460c4e07", size = 223817, upload-time = "2026-09-13T19:08:46.738Z" }, + { url = "https://files.pythonhosted.org/packages/73/29/ed0fda1fcd440cdfc07e72a07f3c9c3b43f65a6b3d53bff2cba9b9de50e8/coverage-7.16.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d0ececb32090e3fbb03e0d352b973a0485879b4de6c58daf47227b9988b99e5", size = 254222, upload-time = "2026-09-13T19:08:48.358Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4e/5e2507fbd71ec1047620978f39568b0d2ed6c4468d08495a0c24f5677f65/coverage-7.16.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f0ba3892d81aacf36996c52f16bca04e39af31a6c5de930b7688ab617f4a6475", size = 256134, upload-time = "2026-09-13T19:08:49.926Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b1/a9f97846554bc240473656f9c3874591105f29c08eccf4282daadc6bc281/coverage-7.16.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6410b75fe07d5271eaa95fc24bd0a9177ed588d9d1c10c0cf67829adb8f0567", size = 258240, upload-time = "2026-09-13T19:08:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c3/2b1ab208d06719394851cdd7dc322157870a93b178410b1bda6cac7755f9/coverage-7.16.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d1039cb2de093225d597109342ce1675626bd127565e80b3044f4eca07c15b2e", size = 260202, upload-time = "2026-09-13T19:08:53.204Z" }, + { url = "https://files.pythonhosted.org/packages/af/14/ff4db31d6e3126d5b72aab1e868d35ec94e1b71dee46f5c3b54cb1b2438e/coverage-7.16.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cf047bc39fde5425be2628666d0f435ed8817859111c3aacc84b32d858069f5d", size = 254305, upload-time = "2026-09-13T19:08:54.833Z" }, + { url = "https://files.pythonhosted.org/packages/ce/27/c28faa4818f61c49ca3bfb4a77e1c191e79a40447504b89af0bd859cd6fc/coverage-7.16.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c05913d0d5badf7ac83200f35dcf9514cce5df16a7cc89e7d1d7fff0461813b", size = 255935, upload-time = "2026-09-13T19:08:56.453Z" }, + { url = "https://files.pythonhosted.org/packages/46/5a/42e64e5b716048cae33e2e15f8c6fe04a927467056c533b8e88900da895e/coverage-7.16.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4027bf6d7bc0a16df058ce913b69f10c5687f8e1ca668f08caa659ce101744bf", size = 253995, upload-time = "2026-09-13T19:08:58.159Z" }, + { url = "https://files.pythonhosted.org/packages/84/e5/860287acd5a1e29acd337f302b476b54148203590091b1a1555b2e914b50/coverage-7.16.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4f48b345f831eaf4402ab6333c2dc3e2e2b5bc7b9c1b8fe12680dee3f0538f01", size = 257767, upload-time = "2026-09-13T19:08:59.958Z" }, + { url = "https://files.pythonhosted.org/packages/a5/c5/4b68006da567b4b413352f891776c35776b3557a0479a28187ab57c21659/coverage-7.16.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8643baeb590726c558b2faed6cd59b0917480f9367fcc692026f1a86d824fd08", size = 253715, upload-time = "2026-09-13T19:09:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/a1/0e/b2d0c47cfbf11770e197f1d0f11a92864eb29fd7eaca45bc9915573d9725/coverage-7.16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d06dcc420b570bf683cdb647cc8fe62b672d9e429ef711c3cbbb7a6880ca1572", size = 254624, upload-time = "2026-09-13T19:09:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/2e/14/3bab8821b2b942155578fc34e88e21b0670d922f039ab23651f6035ba7f7/coverage-7.16.1-cp311-cp311-win32.whl", hash = "sha256:946f58aa59b08bcd6afcc6a7bd0ff54ed5eee844f32ad69fe6814836d15856a2", size = 225404, upload-time = "2026-09-13T19:09:05.174Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ce/4f4ce667a97d1c538b46a79b89161395ad2beffc248b6c782afc9f521927/coverage-7.16.1-cp311-cp311-win_amd64.whl", hash = "sha256:684c7ee9b4c04358fe6ac8b517ab51ec35fcd79d08ff0f105dd8bcd96885bbb7", size = 225879, upload-time = "2026-09-13T19:09:06.81Z" }, + { url = "https://files.pythonhosted.org/packages/26/17/f3dce5e47351ab34522dd037fd64b111742958e24861bdd2820971bc1a26/coverage-7.16.1-cp311-cp311-win_arm64.whl", hash = "sha256:1b24f79e25bcf6c73931aeca7a3dfc7595c0cb5e9364aba3fdf387a3de4b1c22", size = 225428, upload-time = "2026-09-13T19:09:08.502Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f7/7cd4c9f2a3b7574414222ec425d5eee21cc690d867a013315e3be8ba185c/coverage-7.16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b7f2c26ce6ce0b1e0ca0d5fae96ea510e3a2e78b7207f06e76b7f2c87fa3d0af", size = 223475, upload-time = "2026-09-13T19:09:10.145Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f2/9ac65f9cedd43f91e2d3657c11f13aef82aebae89b2243dc9e44fe58a740/coverage-7.16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070acb9da788dff743a4d36fc015feee12d68f0349959017542017c79f59c21c", size = 223845, upload-time = "2026-09-13T19:09:11.988Z" }, + { url = "https://files.pythonhosted.org/packages/62/4d/f13db452d4367fe1122abfd98ae330596f65a3b40498f8e1a5811f68f123/coverage-7.16.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e366587b370bc9b8b51b7b7272c610c56db5d5b4795b9e4a29d28ff2f440f809", size = 255341, upload-time = "2026-09-13T19:09:13.708Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6b/85ed86e82835a96ddfbe7705c387c28ce1cbbecd1b6d606f5ddfeeb712df/coverage-7.16.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e82e10b9d290f60b63459cfb245a841aec347603997206296b93881463a93dcf", size = 258078, upload-time = "2026-09-13T19:09:15.368Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d2/f66945853d850b9c05f4e012e37396f1f31d0cd40d062394b229c22e7b56/coverage-7.16.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d73bb1f85c4150ac208fb0755beb04b2e44897bad81414de9380f98dd74729f", size = 259191, upload-time = "2026-09-13T19:09:17.046Z" }, + { url = "https://files.pythonhosted.org/packages/77/a1/7b907abe62461f289035c7ac60ab3e6334efb8c425151aac4abfa0c97820/coverage-7.16.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3397b9032553d281ad6a9253b12675b65e0cc8cd7a3b0633cf48872c9eb13360", size = 261452, upload-time = "2026-09-13T19:09:18.756Z" }, + { url = "https://files.pythonhosted.org/packages/51/e6/e26f4d6b1069a2a2fee3238a132f85c5b4c1dc25aebd88e015451ff0bd68/coverage-7.16.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77890395cf37026a5907d3ad32376aa51f41c0f163b7477fdbd4f94966cc1d08", size = 255698, upload-time = "2026-09-13T19:09:20.786Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ec/99db7450e813050ebce300e31bbd4f997c76a8c6e3a6d168c5dffc104109/coverage-7.16.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b0944dc3bee3091039bf970d73caaf930c906013128a421bdc132e797494d941", size = 257111, upload-time = "2026-09-13T19:09:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9d/500df9d3cd8c541ac84b5e0bcab00646be75654e05aa34e8410ec851282d/coverage-7.16.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b89d22a89d5bc05dd95b64e08295b8394aa96dc88e08f8ba210c9ebfebbe0489", size = 255258, upload-time = "2026-09-13T19:09:24.429Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/53318d96da332bb4946f8ac646fa19fd37fecbd0c49144735c716ac69bf0/coverage-7.16.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:550a2a1faf7559f13d5344f12d1eb886ad87955155d7dfab2a3fe5c8ec8fe776", size = 259326, upload-time = "2026-09-13T19:09:26.32Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ce/abc0462b2e6ae96ae22197d2bc30fe33b6b4e52937e782745436ceb2a761/coverage-7.16.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:55eb268e5b81aefac759766c9162625b06c1bedb7b77d936225bafc4f038a6f6", size = 254827, upload-time = "2026-09-13T19:09:28.191Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e1/0a04eedaaf19196b51f0180968134228c7646e469ed29914e48690a7cf3e/coverage-7.16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65a8fc80898c9ce59f04349fe8b4849b1f9787f14e52ead990e5f849ff4727a0", size = 256698, upload-time = "2026-09-13T19:09:29.922Z" }, + { url = "https://files.pythonhosted.org/packages/de/1d/d441a55cf22ce9d8e9e34814806c47441ab844bd534e0f4b64e1c6ae8bd1/coverage-7.16.1-cp312-cp312-win32.whl", hash = "sha256:528a61be40977c340cf201d23b69bd6a6bab507da60e9dbda85f8b30e935d70d", size = 225541, upload-time = "2026-09-13T19:09:31.752Z" }, + { url = "https://files.pythonhosted.org/packages/73/27/ec3d032375735dd331477caa051419678079ff90fcb53d0284a6c2bfb757/coverage-7.16.1-cp312-cp312-win_amd64.whl", hash = "sha256:d0f02c633630e2b74522108ee95a84ad6e1204a8016a6cca5297f335ea27147e", size = 226075, upload-time = "2026-09-13T19:09:33.556Z" }, + { url = "https://files.pythonhosted.org/packages/94/00/90e9f5c4434878494306b9c0ee8068ebbd829483737d8cefccc58884d728/coverage-7.16.1-cp312-cp312-win_arm64.whl", hash = "sha256:2959978f9d1d20a2c0c15d0a68baaeccf615ac1aa214cf4a05a10d6f568926c8", size = 225461, upload-time = "2026-09-13T19:09:35.48Z" }, + { url = "https://files.pythonhosted.org/packages/aa/74/c08c0c4dc9fa6bcd1d90728a63660aa1b17b488a806948598456c48f75d1/coverage-7.16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ee5465db6e9152a7d09f3215309326878c6aa3ac509195a369f9d264ff4bfbd9", size = 223501, upload-time = "2026-09-13T19:09:37.276Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c8/784986d326663285258a8e39835c46fb730cc85284f0dbcd82078586dd22/coverage-7.16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2b8256f8b525ba233d2e4cdcdce0d6673c66fc9bf70df1fd5e67c54a74e2d245", size = 223876, upload-time = "2026-09-13T19:09:39.385Z" }, + { url = "https://files.pythonhosted.org/packages/15/76/73fb792928872bbb07e553f920ff55c65ee962c469265feb5d1d4ae5f97a/coverage-7.16.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d57cc400275b9a2892e905fc893f732b21ddb95271bf96406c88e2f6367848b5", size = 254863, upload-time = "2026-09-13T19:09:41.326Z" }, + { url = "https://files.pythonhosted.org/packages/ca/8d/1fd78899513244b065ccecdd1cfc6aa8b8f1bdee796d8c4f2aa8e8e5397d/coverage-7.16.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b3fd0f3435ebb7a7183b32a6062a8b755f08242ced1f3f22761d30b56b3c2a5", size = 257460, upload-time = "2026-09-13T19:09:43.086Z" }, + { url = "https://files.pythonhosted.org/packages/47/1c/b23ddfcb7ef9bd7b3fdb7be9a5dccf9925ae88e556df7aa5b655283c78f2/coverage-7.16.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e5eb1762e7eb5fad34ef913e8107c7788a66f19d328e598ce95bf7217f9e5c8f", size = 258697, upload-time = "2026-09-13T19:09:45.161Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c9/19d0c6ca35778a7e9415c30c46a0c64c9d4374219d004a35563132296896/coverage-7.16.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:46cd3a73e9140410de62cceb66214bce0e08fb3922b9176fbfc1522fec151b41", size = 260827, upload-time = "2026-09-13T19:09:47.061Z" }, + { url = "https://files.pythonhosted.org/packages/97/fc/5862f7344382c62b85df43d483e579168cc062e007f54d895dfa51fe3e7d/coverage-7.16.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d1ba5142d68dd2cb775cbd0ac8601819298152047803c8efe4eec6d7d7aa7878", size = 255038, upload-time = "2026-09-13T19:09:48.945Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9f/5c26583199a68df8d15124b4590520903f8eb7cef17db293fea712bb0783/coverage-7.16.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c389c6f9d1d518e1249ddcb8a7f158135644ce2c508fa6cc17b680777dad5bf2", size = 256827, upload-time = "2026-09-13T19:09:50.738Z" }, + { url = "https://files.pythonhosted.org/packages/95/13/605198bff079b107336710f28a797312ab132587168600d70a290e7ecd2e/coverage-7.16.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cdc57746c7ac0ea063351b4d651c3bb4dd4fd35e64dbb8e90c10e14eb03c4080", size = 254794, upload-time = "2026-09-13T19:09:52.577Z" }, + { url = "https://files.pythonhosted.org/packages/43/b7/0bbb32dc5ccdac766a13763fdfbffd7d73fc80349a69230c46c6b71e7508/coverage-7.16.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5597180ed7670cc94c04c65347418a467d3a43d5f0cf52fcac647f5425f42037", size = 258947, upload-time = "2026-09-13T19:09:54.372Z" }, + { url = "https://files.pythonhosted.org/packages/13/bd/65c31ddd43ff4e61b63721b3dad17cca412dba6e2ce89f92abdf75734339/coverage-7.16.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9647a0ac46255b8fef59a433a2161f03e5483f3a35e1cbd9dfe4600baff0c6b", size = 254613, upload-time = "2026-09-13T19:09:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/25/20/d278115f2ba522b3e3128c6852be265f4e0df7202b2effca4db96cf0217d/coverage-7.16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e306e98186b9cd109121f3583aeb7978797ad21d948f22944c5c08845cd554d0", size = 256388, upload-time = "2026-09-13T19:09:58.095Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ca/d43aa396fb3a2f71c9411b99d926475f5acaf5c124f605c592865c80c8d5/coverage-7.16.1-cp313-cp313-win32.whl", hash = "sha256:48a78a66fcce49d7f6156524bf979c0ac633d584199717c68c6ffa949fc14e6a", size = 225550, upload-time = "2026-09-13T19:09:59.998Z" }, + { url = "https://files.pythonhosted.org/packages/0e/30/df2f6114f6a17cffe94721bd1d298f77f86aad493717fa5bc03cb291a1a6/coverage-7.16.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af03247d598a353bbbbe1b925deb735276e4d845e7197c4073dc89352b236fa", size = 226091, upload-time = "2026-09-13T19:10:02.331Z" }, + { url = "https://files.pythonhosted.org/packages/ee/31/6f90d8aab72a112492bd50e3b5485b53b772b1f5fa70da0a620e723caae6/coverage-7.16.1-cp313-cp313-win_arm64.whl", hash = "sha256:166adae25b05b04c9a84135912066d9c97482115af38df1a419a38aacc6b6f5d", size = 225481, upload-time = "2026-09-13T19:10:04.151Z" }, + { url = "https://files.pythonhosted.org/packages/96/1a/d6d16babd0a5fe4c3fae40702158c570351694e74516d8d81b86c5637448/coverage-7.16.1-py3-none-any.whl", hash = "sha256:3d8bd4e58b6a5c2018d808f297905393c6c61da466a48c3f0596a76a4900ebe4", size = 215264, upload-time = "2026-09-13T19:12:18.895Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "darrow-decisions" +version = "0.2.0" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "coverage", extra = ["toml"] }, + { name = "hypothesis" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "coverage", extras = ["toml"], specifier = ">=7.10,<8" }, + { name = "hypothesis", specifier = ">=6.138,<7" }, + { name = "mypy", specifier = ">=1.18,<2" }, + { name = "pytest", specifier = ">=8.4,<9" }, + { name = "ruff", specifier = ">=0.12,<1" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "hypothesis" +version = "6.168.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/ce/c0946bebffb99b62426a6a7643d4272cc6c5cf777a488b3b4d0ee724e960/hypothesis-6.168.0.tar.gz", hash = "sha256:72af51087b7b5ab21c49f0d502f803c20897678652835596bd2a8b169a39135e", size = 510805, upload-time = "2026-09-08T18:48:36.072Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/f8/8b2cc9ae7b439538f6f2d32a92892340b6343a6b04d171c516666901dedc/hypothesis-6.168.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:47b89491ff02e3ae9b302c440457938e87b47a45b9a1d98ff5575b6910d779e2", size = 791358, upload-time = "2026-09-08T18:47:37.076Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f4/4d7d897310cde5085779fb96feadb8529d98cb8e51ed7b24f7da9b6c6bdc/hypothesis-6.168.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:1f4cd0ff11bd470a1a846296ed5fe55e84214194850370994fd1370fe73d3099", size = 787081, upload-time = "2026-09-08T18:47:16.227Z" }, + { url = "https://files.pythonhosted.org/packages/26/7b/9d52066d363faba7f3ac20ee60a3c696a475feb2c477d235d0d649d41cc1/hypothesis-6.168.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:732ae5d47482f99d8028cca096729625f05690a83f5e7ce31466e266155792f4", size = 1123850, upload-time = "2026-09-08T18:48:11.504Z" }, + { url = "https://files.pythonhosted.org/packages/50/cf/aa46d76fa7df43caf2c372e394fda84ce1dc08421814f8674a6b9295e2ea/hypothesis-6.168.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2085ee74ac3ab6b70e2f7ffae9b4cb74c246da2f574b2de81a0818a8a30f659f", size = 1147685, upload-time = "2026-09-08T18:46:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/84/c2/78ed8c8d5aa37e4baae2a4b3e29687ee3d9b7f1a5e56ea5ea5ec7ec71ecb/hypothesis-6.168.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1894782fae5d9a7bb44e6dcf848ccb09ccb5babab48d8b5c31a0a7fc025b82a1", size = 1149294, upload-time = "2026-09-08T18:47:04.757Z" }, + { url = "https://files.pythonhosted.org/packages/78/7f/d57440f19e9de70e85359cf179ce786f309ee17609bd3c5a0113272875c0/hypothesis-6.168.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecf0ab13cef899efb816ffdd7963e0679f372520884ce06756c7642f3df94213", size = 1169729, upload-time = "2026-09-08T18:47:44.056Z" }, + { url = "https://files.pythonhosted.org/packages/a1/86/dc74410a186990bb22c2a3eea0e77804f2eb0f300860b46d7d8948073674/hypothesis-6.168.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:3f6dcf66270278d078bed01b401f47db4e26456cd909d8e23c6b9366a6c0b131", size = 1129182, upload-time = "2026-09-08T18:46:33.382Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5e/be048fc4f6dac831625e155bdf11e4233caf46bb54030391d6fba8e19449/hypothesis-6.168.0-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bfef4d46dbf1704a7b8fa3a78778651a2cb18870ca0a70da19c381646822b149", size = 1160180, upload-time = "2026-09-08T18:47:26.459Z" }, + { url = "https://files.pythonhosted.org/packages/0f/4a/15a34498a5f08720fbbdbbe4668a8050fe4e17c16c9eeb6f56a017f2fa6b/hypothesis-6.168.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1d1aa5b3484e329295d88488a5ba06243909e65c2ab616513c2d36721de4ed1d", size = 1299711, upload-time = "2026-09-08T18:47:28.34Z" }, + { url = "https://files.pythonhosted.org/packages/77/09/5354e0dae302ab98c4f0046b7e8699c2186ae4349397bda5d5c852bda68b/hypothesis-6.168.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:3bc00fd8cda04b58e37a1163e8a65389b247b4f5ee547ae37d244a4960995517", size = 1425341, upload-time = "2026-09-08T18:46:56.785Z" }, + { url = "https://files.pythonhosted.org/packages/59/7c/3c0e1f59043ff128c70a51d298d3d6b5973525c357a1e1d0542dbc05ac90/hypothesis-6.168.0-cp310-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:990026952d5b2eca290c88f639ac639233f47e13dae338c6dfb6e4774bcab349", size = 1281063, upload-time = "2026-09-08T18:46:55.401Z" }, + { url = "https://files.pythonhosted.org/packages/40/dd/db884db9a7d42ae6b72a00c13c725940b638dfb263e618b22af59d5dfa2f/hypothesis-6.168.0-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:a74b0945acbbd552c7c2d0a99a3b5232962b8848c8eed1829451800a9bfcf00b", size = 1300247, upload-time = "2026-09-08T18:47:02.949Z" }, + { url = "https://files.pythonhosted.org/packages/99/8a/4ee9769e1d48676efb6a78a130f82e0d52d3f87b2055294102272a08615c/hypothesis-6.168.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a380b521b5a76a9e8917d64adcf7f861a45a4360a34b1579af14c5df8eb0377", size = 1336084, upload-time = "2026-09-08T18:48:05.675Z" }, + { url = "https://files.pythonhosted.org/packages/61/17/d4ed11bc99d205d6d2651a0f1a4874f377150f836b5a0849bd511d68a2eb/hypothesis-6.168.0-cp310-abi3-win32.whl", hash = "sha256:2264f15a1c80329e3ad48e39c44bd5c9429b7b04c9ee62cdd72f4b10aaac9f29", size = 677989, upload-time = "2026-09-08T18:47:24.722Z" }, + { url = "https://files.pythonhosted.org/packages/77/51/abf1fde7b8afab87db30afb73b3847e62440551d146472111cabeba2fe00/hypothesis-6.168.0-cp310-abi3-win_amd64.whl", hash = "sha256:5b54769033b84477931d2072e7133a7555e0de5c53fd5ca3bbde960762d7d31b", size = 684692, upload-time = "2026-09-08T18:46:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/12/e2/64d79aed47a95186ce9edcef60eb4684870c72542da3b7027c2483d8ee8c/hypothesis-6.168.0-cp310-abi3-win_arm64.whl", hash = "sha256:112b0900059bf9d7d6528ed729770629ab146e0d133c4143b9bd4a01dc002bcc", size = 682709, upload-time = "2026-09-08T18:48:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/95/67/bfacadcc6cd4bd179d1cd2f6c85f580136019b38af7db71e3159ebb3ce33/hypothesis-6.168.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:cb10aa59b0af45badca76911f5323f40d24fdbe00d01b7b67fef8648c99411b5", size = 792193, upload-time = "2026-09-08T18:47:42.392Z" }, + { url = "https://files.pythonhosted.org/packages/fc/29/4abab4114035f54438e3b07bdab3f68fd8769fe0fb84f8ffd6656eaa58fd/hypothesis-6.168.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e21e30b76b6d3adb87c550576132a3204f4c257ec43353f6c09b9d59bb762abc", size = 788121, upload-time = "2026-09-08T18:47:09.398Z" }, + { url = "https://files.pythonhosted.org/packages/85/d3/c54242439dcb59c7e3274a2952188bebbc605be397c3a6ff027fc81e1726/hypothesis-6.168.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:527452b43e79e6dfbf9cb69145a940547a3cd177c556698a3fc939ed2354c4b3", size = 1124222, upload-time = "2026-09-08T18:46:28.345Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8b/1c9f54be7040fe7d94e4cd5d9343da5a36240bee2fa856f44109f9622251/hypothesis-6.168.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:489d5c060f49f495b64215cae627c71730cffd5ef59dc4d7f431932e6e6d2e67", size = 1170346, upload-time = "2026-09-08T18:46:52.784Z" }, + { url = "https://files.pythonhosted.org/packages/af/d4/f6b1557bd3cf2976d56465fcbc483214bc6e7f1aa7e4db9f8c5f776d547a/hypothesis-6.168.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c3af200b322f710c76c2189866246cdcff2039165dd77edff1a7bf1157162fb0", size = 1300267, upload-time = "2026-09-08T18:46:45.644Z" }, + { url = "https://files.pythonhosted.org/packages/e5/ad/a345588acc038deb09a43610328004d31fe5a53289627e03b0199978dc45/hypothesis-6.168.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:73084b76e4a79cd0f7883ce80fc60c9f374ce7dcad8f520b39db40470ce1852f", size = 1336724, upload-time = "2026-09-08T18:46:44.179Z" }, + { url = "https://files.pythonhosted.org/packages/97/e9/c5e380fd29fd552ff442bcfb1090926287e2911d40212ae0602a0736347b/hypothesis-6.168.0-cp310-cp310-win_amd64.whl", hash = "sha256:8067e6b4b48e5cfdc849a1a20c9d4972b3f532b3e3edb5e2b5dfd106045a5236", size = 684644, upload-time = "2026-09-08T18:47:45.814Z" }, + { url = "https://files.pythonhosted.org/packages/a6/7a/7a28d9afd52c9a89c4d8e3170c92fe1ffbd4a4ce3f905ca15fa7ddaaa581/hypothesis-6.168.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4d7d29dd63ad9fdc4aa1d65fa272449e14aaf6c6bb8451091818c2945533a43a", size = 792045, upload-time = "2026-09-08T18:46:29.612Z" }, + { url = "https://files.pythonhosted.org/packages/0a/40/e2d6fe12b54abbc8b802b234ec4974d0a420dde4098f57a324e9c791ac27/hypothesis-6.168.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9a72ed7afa1f7e30488b8a5754fca0ad9755518bdb77d6f0b003cadf7437a5f9", size = 787932, upload-time = "2026-09-08T18:47:11.145Z" }, + { url = "https://files.pythonhosted.org/packages/ff/89/cc78e05f3248949c6d9ecb489d0e422247d11b08533ab02e177a10a01610/hypothesis-6.168.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fcc5bad4300a751804ce41f0e10d77f85272668160708ce39ec579bca8984843", size = 1123994, upload-time = "2026-09-08T18:47:12.647Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e3/7d483a79e2ed9a6c868cac8783b3c6c2bb82303f7db364c9da9a406df10d/hypothesis-6.168.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53469a1a7c4861b12c9a8622f762d7d1fd7bcf171884e1018ed5a8f063a5c063", size = 1170271, upload-time = "2026-09-08T18:47:54.322Z" }, + { url = "https://files.pythonhosted.org/packages/78/87/bffaf5b5e6b4566aa40d8a4ae25d8d84b47dfe0d9e771f454e4974cdb428/hypothesis-6.168.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a9650c4882fdbdd8e90bdae602a8bfa8c6f09dc5d06afec5b9b23982e8f60a04", size = 1300162, upload-time = "2026-09-08T18:46:27.022Z" }, + { url = "https://files.pythonhosted.org/packages/25/06/6c30a00fc2f5c58546e858259cf2261c6a3ef6a060c373e00d00311267be/hypothesis-6.168.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:348d9b93fd4129f67f9bab94f3d70709a9372bbe0e0d22731325ce85d5eb409f", size = 1336294, upload-time = "2026-09-08T18:46:37.324Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b1/501910e5a6a9c245f2394fda016376f6c7e209c5dca8c056eaece64ad10e/hypothesis-6.168.0-cp311-cp311-win_amd64.whl", hash = "sha256:719b45b0512e3535a6a0077c2f7c6053b02ac0e72d60693f66f98790a33855b2", size = 684464, upload-time = "2026-09-08T18:47:19.779Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5e/0035896c101f0484c364353f8ee30175eef8936b49171618677287fdd85d/hypothesis-6.168.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6b750390dac4429da0cb70ab3fe758457f0cea3d9c843d48c59d0690d1189fda", size = 793115, upload-time = "2026-09-08T18:48:21.352Z" }, + { url = "https://files.pythonhosted.org/packages/11/5c/938173e27df771cc6e92bc47f117a1b1be4a88fc6dc214f7fc65f9c7ad93/hypothesis-6.168.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8e4b2d434e0dd134f3d31ac1efc1825bf99730dfe70fec005ff66d7211836d79", size = 784634, upload-time = "2026-09-08T18:47:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/00/0b2c6ac07d519131f97712f2533750ffe9b2490eec8f518ef3a5ed2dd514/hypothesis-6.168.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d4d36ed2fd62de11382f1d608169c1ffa9a49d3b9351146d8ff87cb81a66f7", size = 1122855, upload-time = "2026-09-08T18:46:21.967Z" }, + { url = "https://files.pythonhosted.org/packages/8d/21/dde930fe43171cab37572bd993d70c2a271f240f428f8ccd64ec4c2d661b/hypothesis-6.168.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5920d267f7d8cfd376672f2bde5905cdf284d47519582e41ce7c142d48ee46c4", size = 1168932, upload-time = "2026-09-08T18:46:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/4c/5d/92b83c3d06194ec626e92723d0b0f70221ebf42d7cb355ed36929df6d735/hypothesis-6.168.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:fb8cdf45361e259df86e19f8cd042ce2d6c7e6ad88fa631b78a4e3a83c2e572d", size = 1298566, upload-time = "2026-09-08T18:48:19.485Z" }, + { url = "https://files.pythonhosted.org/packages/9c/67/52de8bf3446e3d2d555b812d96673b31bb213b5b9d5804a666e5d0bba76e/hypothesis-6.168.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3b3ce1cce70b25a37ed1a38a53ce7204785726c675c0f41a0f83c338a7e47b3d", size = 1335074, upload-time = "2026-09-08T18:48:01.561Z" }, + { url = "https://files.pythonhosted.org/packages/14/fd/e592773c1c0ce55e35d26ec55f75546bf1fd72ef5a5c520ed685969b40cb/hypothesis-6.168.0-cp312-cp312-win_amd64.whl", hash = "sha256:f62bdabf278db9ff61df5f3203d608949f0d893d0e30cdac3f2330e67e41ae68", size = 682026, upload-time = "2026-09-08T18:47:01.471Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e9/39bb8fcccfbafd10fcc777d583c6ebad5d2148e7d743cb350562f28e974f/hypothesis-6.168.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d55562bf8d41cfa18559c33f30cadf44ceac8e517509d7a022a9feace621f28", size = 793050, upload-time = "2026-09-08T18:47:56.045Z" }, + { url = "https://files.pythonhosted.org/packages/f7/dd/00fd32e8ec470535e0065cb8d6e175f9fc54b4d3a6f1269f6c487f8bd79d/hypothesis-6.168.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92cff497b92e2285ff6a94193fdee04aba483a4115d501c1f9a570bd103fcd20", size = 784558, upload-time = "2026-09-08T18:46:32.054Z" }, + { url = "https://files.pythonhosted.org/packages/d2/4d/553c47093f68bdbac0438e16c024ce97649b5804dc6972ef86b9bd2db8a1/hypothesis-6.168.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ff259260015f9be3756dcd4bc11c08e007314dec6b43d9a89084c4f34f94475", size = 1122841, upload-time = "2026-09-08T18:48:09.374Z" }, + { url = "https://files.pythonhosted.org/packages/43/d6/0b5940aa75e617c8fd12200bae24d1b71347362514e8210735c581d4d3d1/hypothesis-6.168.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35f1262831b5acc74ded15f629965daffcd657f6016ee04fc9605f6eb2b334c0", size = 1168825, upload-time = "2026-09-08T18:47:33.702Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c0/800de1231b2869b51409bbf85799d6f1bf49a00e0afff0aabc097aa8f1b7/hypothesis-6.168.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:046fe4bcfce2a2fa186ba9d96bbb62c25c2f6c2e4071f0783ed6b5cc481d0669", size = 1298433, upload-time = "2026-09-08T18:46:38.53Z" }, + { url = "https://files.pythonhosted.org/packages/be/35/9907667a30c1dbabbc44a09b4c25a0f575570937fcbbada924ae3a1dbf2a/hypothesis-6.168.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:24b52a2b1c8db6e1e516f9295c8e4ef7ef63303ff24fbbc5b35f4ff71dcd732c", size = 1334988, upload-time = "2026-09-08T18:48:29.395Z" }, + { url = "https://files.pythonhosted.org/packages/98/8a/7bf214e703fff532ed47cb52ab93ce4b7ea41e4e7084593fa02b740828b7/hypothesis-6.168.0-cp313-cp313-win_amd64.whl", hash = "sha256:ec0886fe0be9091669937989f9a662beca42ae14a4a6dab25491c2c63365f88d", size = 681990, upload-time = "2026-09-08T18:47:49.173Z" }, + { url = "https://files.pythonhosted.org/packages/16/f7/5adae1bf1d4877aca2e8c8e007e57077237e9ac3765e430ffda490b17e19/hypothesis-6.168.0-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:754016594fe78cef91790e0922f60d183c52f531255fbfa30dac495b813e2128", size = 791056, upload-time = "2026-09-08T18:48:27.398Z" }, + { url = "https://files.pythonhosted.org/packages/f2/93/b1b2770b87591db5cf564b9aa0265cf21e9207d28645e0abdeb8df63225b/hypothesis-6.168.0-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:6f0dd437ec01140676192422b61f2f833b3ce6a3213da9b7e196ad6b3777e795", size = 782980, upload-time = "2026-09-08T18:48:34.087Z" }, + { url = "https://files.pythonhosted.org/packages/de/bd/673171c1d2423379a7d4a0f9f009cca735a422d0c4a4ac6422d1d1736cab/hypothesis-6.168.0-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f77af7721ff35a58fa8797decd14c932c350a2548686c6e9b844db710a3a2441", size = 1120964, upload-time = "2026-09-08T18:46:59.92Z" }, + { url = "https://files.pythonhosted.org/packages/7d/00/33a9bd941b22a4fd8a8c805b1563e0db17efc020422f5bd15bdd0fdf258f/hypothesis-6.168.0-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a0d28418c104d7268fdebcc09bc49f7b6569b5eb942430c6859f53ec8d4edf63", size = 1143869, upload-time = "2026-09-08T18:46:49.875Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c1/963460976f41721eff8f67f30d31059ea407cc1b41c737c8859aabf37197/hypothesis-6.168.0-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:812a84c4cc7f7ae4fcb39a5647cc2698e6c18254f8423126425578f1dcdac782", size = 1146453, upload-time = "2026-09-08T18:46:25.757Z" }, + { url = "https://files.pythonhosted.org/packages/ce/53/09db238098ad66f4e6d2fe883f26c270c2595b90e21c8f969d4cb21cad7a/hypothesis-6.168.0-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6de30e559eb151de14a5f74bceb4d97792a9315ada2a1816b5da825cd7d28edc", size = 1166918, upload-time = "2026-09-08T18:48:23.436Z" }, + { url = "https://files.pythonhosted.org/packages/b9/31/e1b7b452c8a6166e445ba2ad80a864f6a9eee0fe4c8cecdb9af5c1ee0aa5/hypothesis-6.168.0-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:9018b20acdb061b2ef4b2fa7f558ca5db97ffea316e0a528bc003a24b2ac996e", size = 1126637, upload-time = "2026-09-08T18:47:50.878Z" }, + { url = "https://files.pythonhosted.org/packages/97/2b/4eceed248afb46fb6b2df21cf2239362de5b25d295c2dc67a82ec8657d5e/hypothesis-6.168.0-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bc935a5d5f86fd8f5af951b8fbe00307f6f7c596f82a9a27c17d974f6ab0a26c", size = 1155682, upload-time = "2026-09-08T18:47:30.234Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3e/f3414cda4f325004d5774485e8983b7d1b99e4b91013f013dd088fd778cf/hypothesis-6.168.0-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:45fcfa05f746e253350f55f216bcef59754f5f2b85745f1fc2bb8ba81dd517a9", size = 1296464, upload-time = "2026-09-08T18:46:34.833Z" }, + { url = "https://files.pythonhosted.org/packages/aa/40/ca79cf96545e1f172b36b8df56bfeb02b61f351f283026f9a57c4631e368/hypothesis-6.168.0-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:f89d8e998d3c936ffbbd1c3686c96f0378f6558aecc5967a3035a857f2bab0ad", size = 1421853, upload-time = "2026-09-08T18:47:23.083Z" }, + { url = "https://files.pythonhosted.org/packages/77/bc/657d5386740c1f4ac518ff05e4c5b132f1e6df2e7c865ba8fda4279adfa5/hypothesis-6.168.0-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:d0620fa320fa66649e6bfd71e94f3f86115fffebb7e3c6dcece19d1aaff8e07f", size = 1278216, upload-time = "2026-09-08T18:46:30.871Z" }, + { url = "https://files.pythonhosted.org/packages/51/54/2328cdb70489a36634534478d9b238594a269d8bec4630ea0e6897048347/hypothesis-6.168.0-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:4085b61e25d3dcc6c9151d4115269870aee8cdb921611ee5c989b2786449be09", size = 1297593, upload-time = "2026-09-08T18:46:41.395Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ca/d803fa57e3ff7f460f6e262d2b74822cf143343d378501fd3da601b12040/hypothesis-6.168.0-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:b5449a64eb37d9a4aa6ac9cd2ab0fd1a24145adf421ef1536884f73f39824887", size = 1333785, upload-time = "2026-09-08T18:48:25.434Z" }, + { url = "https://files.pythonhosted.org/packages/86/8f/b9799ae6ba6074f151db2c63f9f6f12d844512821ffaba1a3672d7e59f07/hypothesis-6.168.0-cp315-abi3.abi3t-win32.whl", hash = "sha256:91e3de666a6c4f7543000d1710e25055d63ef3032c98bd2ab338b3087bdaa780", size = 675173, upload-time = "2026-09-08T18:47:52.601Z" }, + { url = "https://files.pythonhosted.org/packages/61/54/14c3e277b451ff24128ecc2673cac59dd7e535bce1a433c466912fd682e1/hypothesis-6.168.0-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:9a2079cd09919956dd388f1a1f8ea5a79f2b2437650fbeda31d8661217ffefef", size = 681491, upload-time = "2026-09-08T18:46:51.437Z" }, + { url = "https://files.pythonhosted.org/packages/99/f3/827e4a48ffee7e40244b0bf064ba47c2171e053ab7edf1cf770105e23401/hypothesis-6.168.0-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:085c9aa246487c56a40ca89003d285cbffdbb5be4097ba6d0139f9c21003c04a", size = 679197, upload-time = "2026-09-08T18:47:32.112Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/7f15e1b10d13e266b0f09cf3117dd2206104a1f6ac3b3baf6ec4c43b7c2e/hypothesis-6.168.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:16864797de4b024e4c6cebd44598af932f870aad811341bc5bc24c738801ff76", size = 792946, upload-time = "2026-09-08T18:47:35.438Z" }, + { url = "https://files.pythonhosted.org/packages/10/fb/32487bdcf68b3805ec5efcb7f92fb002b573ad3f90c252d2f2913c5d3124/hypothesis-6.168.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:283eda952bcb1987ccba1c8b634db0e8a960e1e92e2daa7003bc2392f19cea01", size = 788783, upload-time = "2026-09-08T18:46:42.862Z" }, + { url = "https://files.pythonhosted.org/packages/13/26/1aa42e0069ebc68d29d980d47420af324fa002494e01c1c0321c34f609ba/hypothesis-6.168.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5427a3c951080c18170486f775df6a82153882b819eca6b8e7ed77693634e5ab", size = 1124762, upload-time = "2026-09-08T18:48:07.512Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4e/99509045ed55aaa05d8408663051a93168d1a8a5bfc132d92ec1584ce47a/hypothesis-6.168.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a838218ff1eab8d7b4bf66b96037fce0a802f61f2fa5fd4b784696cac365ce7", size = 1171748, upload-time = "2026-09-08T18:46:47.029Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c9/96e57dfd89913322b5180b031b9e36a29bfe67842108b19904466476cd24/hypothesis-6.168.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:34e3c8b66047ba92f8b8df5e427074058d92db58038f007da4bf9d14e934ad3c", size = 685447, upload-time = "2026-09-08T18:46:35.97Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "librt" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/12/e2e9ca532cf5a0e08c9489826c4a35c6958c92ba0313fda70e8c6c3912be/librt-0.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1a49adf16a7c9d9646816c2946135527197b6fcf4347c7b8b761cf1bfbf4489", size = 148673, upload-time = "2026-08-07T10:46:22.569Z" }, + { url = "https://files.pythonhosted.org/packages/6d/7c/02005e23478bd5950618d9712e0fd2b4c511657857f3efd8ba6a5feabcdd/librt-0.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81a398f45b45a59200e13cd5ad1ae1d3f44334de98b148331afe2cdfee701c52", size = 153547, upload-time = "2026-08-07T10:46:23.931Z" }, + { url = "https://files.pythonhosted.org/packages/a0/90/d8848a735f5642077fc4b3b4bebcdb08edf10178e3add45597f5201a368f/librt-0.15.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4eafbaff06b9563f8b1c850621ce51605de05208e09d4d71ce490bc972b7b9e8", size = 494355, upload-time = "2026-08-07T10:46:25.122Z" }, + { url = "https://files.pythonhosted.org/packages/e1/0b/8604f41ea02feace490e9e405a338a15f9905369f55b239a9ce31c946f24/librt-0.15.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b0411b4066db926b80258c60dcb0e6db4c9cee312eab45b7e8866b17ddf9ada1", size = 485459, upload-time = "2026-08-07T10:46:26.447Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ac/84153bda1ce0da609182527ab92b40d961809e544eefdc5a1c2422971416/librt-0.15.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:febb1ce6cac545a54e6b769982824e955a700fdd9fbf3a08a3d82c990968b57d", size = 498398, upload-time = "2026-08-07T10:46:27.701Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3a/5ca6cd282b2c244bec8ec84102e09773264e9c02891d56ab3a8f0e4d7083/librt-0.15.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b230acc1c3bfe2d6f2627ba2b95dc92e58aa494600e9722d0e6ccbc931e59702", size = 515474, upload-time = "2026-08-07T10:46:28.9Z" }, + { url = "https://files.pythonhosted.org/packages/73/d3/bd34110234779eb843c6ed66aba7c9b2091d3dd85989f1fb9922f564cb7a/librt-0.15.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6da110e5f314c19ab8478464d02ae18808ae73d522c15260fa4918acdcd64da9", size = 509484, upload-time = "2026-08-07T10:46:30.124Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/43c3f7f071d71631a7daa3b835ef2168ea39f20692d81464d4e47fbaa6d6/librt-0.15.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:eab9208b00ca55bf75983ec99f7bf13acc746a36102e98953addaad7f7ea1e1b", size = 532534, upload-time = "2026-08-07T10:46:31.511Z" }, + { url = "https://files.pythonhosted.org/packages/c5/1c/b854adf036ea817c40408873a5b794d65a91d9f0f39826f2ad2a2d5d7f48/librt-0.15.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6c013cd3a1721e69e14380ada97eaa4b7b0cdf1c6b96fa765d4ea47c875088db", size = 537087, upload-time = "2026-08-07T10:46:32.734Z" }, + { url = "https://files.pythonhosted.org/packages/25/5c/c9a890e244e7dd725d3bd8b560e41f0aec787eaf343b46956a290ab7b841/librt-0.15.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:567b1c430f8bd560e689421468278ac5941bab4a05303b5d95b6ae10db03f451", size = 536575, upload-time = "2026-08-07T10:46:33.965Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c5/c8e70b60b704299555f55db468eb46b1c81bfc60201ffbfe20407d89870c/librt-0.15.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:29c4cab9df457b19672c39be7f384ebb2bc925c4e2684b8780c222b43eb36389", size = 517142, upload-time = "2026-08-07T10:46:35.577Z" }, + { url = "https://files.pythonhosted.org/packages/56/d1/767a90c41f5d381b3195bc88ac0ec4afda35777c9c781e1f9848fedd965e/librt-0.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bccbd8e5b0bffb7106cf18eb1baa3d7194b1cebb3b4b1cdbd4bdb19382a6ee6c", size = 558714, upload-time = "2026-08-07T10:46:36.829Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b4/3c0624b8dc8301ab808f2b3a910995bcabe28df070fb9a0e5505ae997dae/librt-0.15.0-cp310-cp310-win32.whl", hash = "sha256:8ae493ed5f659a7761c43d42f183db514536073ded9bcf671d2d1df47e29a07e", size = 104426, upload-time = "2026-08-07T10:46:38.594Z" }, + { url = "https://files.pythonhosted.org/packages/31/98/e91c0382304bedb2db9c6801897319a9dcb68daac5e975819b562362f20d/librt-0.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:bc25fb356d0c7810bb49ff3df908ad1fda6995d660ab099ded69244ed7ab6053", size = 125057, upload-time = "2026-08-07T10:46:40.052Z" }, + { url = "https://files.pythonhosted.org/packages/59/52/06790ced2ac7117f890c21bda43c39c958ec82aa665c0718e821d33ff939/librt-0.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:823b92cf3c18ecd08afc70c42473888b41b6e8ef5046f3b82c05c154a2fa3d22", size = 148039, upload-time = "2026-08-07T10:46:41.165Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1d/8e150b7fc449a1f33c8a760965cc1f43b14fc1577d9d0b50ab2701420e74/librt-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c70bc1b602cf59917e8f0c7a2cbc8bcc6fbc14d5486136b00707a79619121d63", size = 153067, upload-time = "2026-08-07T10:46:42.418Z" }, + { url = "https://files.pythonhosted.org/packages/51/87/a162bc5a66a35599dc619ecb215145f4de7d68e886b479b6d12593139f7c/librt-0.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:814ff83a25b5fce8b9c80c4dd803153fb5c5599fc74db9e022466938368957ef", size = 493087, upload-time = "2026-08-07T10:46:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/e5/3a/aeea1fc620cf48060d3065b37614edbf97043c099d0f50782bc8ca61d897/librt-0.15.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:57f5eeb6ad4c180de583b1038e61fe5fbd9796bb69a8a1c1a0c7ddbec4c8c60f", size = 485608, upload-time = "2026-08-07T10:46:45.038Z" }, + { url = "https://files.pythonhosted.org/packages/52/ff/fe571ad416f0856fd0d5578ffc2e6dc531891e586e36b647bcf50569cab8/librt-0.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82909c8f7eb9952656b65d3147afde4cf8e6d5a991eebc86418b5e65843b0ab8", size = 498723, upload-time = "2026-08-07T10:46:46.35Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e1/7a65eb5dedb1f00aebd948cdd8e17add48bf066cab3514e9daf84ab45a6c/librt-0.15.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f779070399f991400fc451719e0ea388eb7de313388bada2c127a35de05f798a", size = 516002, upload-time = "2026-08-07T10:46:47.599Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/59832b0ebfbd08c2742e6ece372ceb53f18bf1faef5d33c8daf3abebf749/librt-0.15.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bac89069bc496ebdf4f79ebb57bbd10d0b214c8454225deb672d91002bd17e18", size = 508607, upload-time = "2026-08-07T10:46:48.873Z" }, + { url = "https://files.pythonhosted.org/packages/ea/0d/37fa73f3b43ebd8259f91ae9102a15e5a54e65d581e48dea72df3e81d7a4/librt-0.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e0d00c708fb2f5822b152429b1ac80a58dbbbc3f6c232c4d13a3f7fcf2ea5b4c", size = 530422, upload-time = "2026-08-07T10:46:50.45Z" }, + { url = "https://files.pythonhosted.org/packages/26/02/e046c6fe7a5881ac34623242192f484426ba8a75595fd18f22c53a3f530f/librt-0.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6c6624fe268625869485553dd7cc1daf30d22558215bb2a4ff16f67a9801a31a", size = 534303, upload-time = "2026-08-07T10:46:51.693Z" }, + { url = "https://files.pythonhosted.org/packages/95/32/d5e6d861ab0366f3edf74f887ab0c9eb9f535aaf01d32b80b4f734daa179/librt-0.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f56b397858a23dacf35ede366ed2212fdc03a6a57a1ad36468ad6e9dc5fac091", size = 536084, upload-time = "2026-08-07T10:46:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/2a/de/d69d725513fe53fc90c6d7a1f86e4428939bad2fb905b17fe4c18d413dde/librt-0.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4388184646efe2054911c5b00a1077d6d1ee86a95b7e8ba96dc7850a809f3f40", size = 514307, upload-time = "2026-08-07T10:46:54.194Z" }, + { url = "https://files.pythonhosted.org/packages/36/93/f8aded0d6682b4f25820fa86e0690f87f01df9fd7bd09ddb04d9167ad021/librt-0.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:97335f59082f9fe2ce6c2a9cc6433a0114bbb6cd4d5c09dd76c95c68b9f9a8b0", size = 557686, upload-time = "2026-08-07T10:46:55.443Z" }, + { url = "https://files.pythonhosted.org/packages/74/09/ffeb6bdeb6cd862b4272fddc8ad05f938dd25d020ed517e631813917d80a/librt-0.15.0-cp311-cp311-win32.whl", hash = "sha256:83380ffde38062a2e9bb55d83e74474f6614665528b98a6928720fc006dfffbb", size = 104917, upload-time = "2026-08-07T10:46:56.605Z" }, + { url = "https://files.pythonhosted.org/packages/96/28/7e2313a3ffbf0b4de7ba3da58a09e488507b4bd1ea2b5e69378354a23415/librt-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:f75720477ee05d509a310e856cacc8d909adc182f7b91193c207bcc26d7ee6db", size = 125886, upload-time = "2026-08-07T10:46:57.729Z" }, + { url = "https://files.pythonhosted.org/packages/39/9e/04b8c3cde014ef255ee785730425268354543acc38902093a40afa0dc164/librt-0.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:256237037a3ab001ae8d9803b2d43562a4c3aa38739843694349e4d5ebb0fd56", size = 111885, upload-time = "2026-08-07T10:46:58.787Z" }, + { url = "https://files.pythonhosted.org/packages/ba/39/99c25030e782bdfb7a21be8c05254806a2e4bbb05c8d50c2a2130acbfa05/librt-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e", size = 151021, upload-time = "2026-08-07T10:47:00.057Z" }, + { url = "https://files.pythonhosted.org/packages/14/43/f4b1bd1b2888798a1409808889a25ea1ba49eaabce7d681ed27734c2df9d/librt-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d", size = 155267, upload-time = "2026-08-07T10:47:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/0c/db/3ad9c965c72f1e1d6beeec44ec10a54e17be8ae042fbb4baade16cbadced/librt-0.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1", size = 503136, upload-time = "2026-08-07T10:47:02.45Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/5888a6d76acd62ebce66c61b74d94e9370b9c32929f111e487bb6546f8ed/librt-0.15.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa", size = 496670, upload-time = "2026-08-07T10:47:03.675Z" }, + { url = "https://files.pythonhosted.org/packages/29/39/ab57cc2f5b276156da02bb7f5a8921bada1cb1993ffec99acf811c602c23/librt-0.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd", size = 513688, upload-time = "2026-08-07T10:47:04.981Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/bdbb0b648b5c2befb031f4c6f3b1dd857415e8fb492a25a3c764a6681e6c/librt-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa", size = 531904, upload-time = "2026-08-07T10:47:06.211Z" }, + { url = "https://files.pythonhosted.org/packages/93/26/473c2e4b6c104e9e58e27ce95fc8005c8bd4fc36cae4f254371125a92db8/librt-0.15.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d", size = 524427, upload-time = "2026-08-07T10:47:07.592Z" }, + { url = "https://files.pythonhosted.org/packages/26/60/03b3abb82b41714671b907bf6989b228e31e6a8af52dec82b5b0728dc250/librt-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656", size = 543155, upload-time = "2026-08-07T10:47:08.866Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0e/9bb1f0a4affbd0a1888f4f79dc03ed2a299d9a2c26c59ab2a97dcbf11903/librt-0.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81", size = 546890, upload-time = "2026-08-07T10:47:10.327Z" }, + { url = "https://files.pythonhosted.org/packages/dc/84/6937a280d461f7de6e031ffb02edc2b7c3c90d49d630565ce8ff27cbc5f2/librt-0.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d", size = 555163, upload-time = "2026-08-07T10:47:11.798Z" }, + { url = "https://files.pythonhosted.org/packages/bc/95/2a2853c1ee014bf102116e7f897a04beeaeb2461b45b79af98bdfb95f1ef/librt-0.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0", size = 535812, upload-time = "2026-08-07T10:47:13.279Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4c/cf9601c1b4c5f09280acd5d83abdb2e68527a2be8257136eb42304218622/librt-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2", size = 573688, upload-time = "2026-08-07T10:47:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/47/6d/9ac7cbec46189a7625af4b5acbd25f10d827f4141b2002181848c8418923/librt-0.15.0-cp312-cp312-win32.whl", hash = "sha256:a5207ec414d1c4a2a7231b2086970dc036f94293cdf338190984958a013a42f1", size = 106138, upload-time = "2026-08-07T10:47:15.973Z" }, + { url = "https://files.pythonhosted.org/packages/38/d0/2ae99c83be86ce23f925ac1aeeedc777e97f427c4a8d190c70d0a16e9a87/librt-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:73b30cfa976659b3917c8f6153bdb0591c6a9ec6583599fd24a689b690622022", size = 126974, upload-time = "2026-08-07T10:47:17.049Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ef/dd24f9635c730b86b87587967dda7516b1845e8b17684603d31607fed598/librt-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:a54cf9e0ef47b96af580849db5471142200568ce1e02cbf416addab551369570", size = 112292, upload-time = "2026-08-07T10:47:18.222Z" }, + { url = "https://files.pythonhosted.org/packages/e7/42/467b53a601b406ccd7b97c1fd54b59cb34f9185ad5ce7e9d5c3c4e8961c8/librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26", size = 151029, upload-time = "2026-08-07T10:47:19.312Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/36c2299b7a94b84fdd01220d8a777a71be5be0925bb0dbdf71c0a06a34d9/librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801", size = 155194, upload-time = "2026-08-07T10:47:20.398Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/ed5071f9325845e670bd36012757419767fbf56af77ed483077b9e4db541/librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc", size = 502568, upload-time = "2026-08-07T10:47:21.652Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/6450c67c3615d87704bcbc21323fafc69c799b06a044c447529f725d4b01/librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95", size = 496153, upload-time = "2026-08-07T10:47:22.925Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d6/5f52b722bc75076954b3bfd49be15ea362df4d580c6fb315d0f617100d30/librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b", size = 513336, upload-time = "2026-08-07T10:47:24.213Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e2/c08fd1d36ce63ea5a12b85c5d37f4550b5f86a692167e41e5a74222607ae/librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2", size = 531661, upload-time = "2026-08-07T10:47:25.507Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d8/d9482fcbeb177b9eb87bb3899eeb3b42be690313c652f9e146b1d0681fb2/librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3", size = 524487, upload-time = "2026-08-07T10:47:26.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/075171517b41f861753034fbb151b42cfc83bcc853849f24f5e66fd60ccf/librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785", size = 543201, upload-time = "2026-08-07T10:47:27.999Z" }, + { url = "https://files.pythonhosted.org/packages/b0/03/42c2330f37eeb475b6affeedd06518f60035f323af3a839335e3fc9fef2d/librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6", size = 546467, upload-time = "2026-08-07T10:47:29.396Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/1ad4c5638f7e64d8560328bd25c54b409a661bdb6ff254b38ff90744288d/librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101", size = 555139, upload-time = "2026-08-07T10:47:30.815Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/39fa7d15db1204cd1cbe6514680fbdc243adf754a0885061308f43afc013/librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218", size = 536050, upload-time = "2026-08-07T10:47:32.222Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/c6dcf0dd8e26dc0c9a499a2abab8646c86dcaf9ecea9524cb46d3686331a/librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b", size = 573700, upload-time = "2026-08-07T10:47:33.527Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9b/ab54c71a7918a7c34fa5327fb61390a77446a07a146fbfb1165250a61035/librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab", size = 82194, upload-time = "2026-08-07T10:47:34.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b2/4f9a243bb892395f3becb80789ade13771701091f9f07ab8230247953ba8/librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890", size = 106231, upload-time = "2026-08-07T10:47:36.251Z" }, + { url = "https://files.pythonhosted.org/packages/bf/af/64aff4885a40b93132382f2c314647d722574605416504379184ef3045ea/librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8", size = 126996, upload-time = "2026-08-07T10:47:37.453Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/335bccf6c7cb9028cb0b54aead27d9ece3f01f83bc6baa2abace5da655c1/librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad", size = 112188, upload-time = "2026-08-07T10:47:38.729Z" }, +] + +[[package]] +name = "mypy" +version = "1.20.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/97/ce2502df2cecf2ef997b6c6527c4a223b92feb9e7b790cdc8dcd683f3a8a/mypy-1.20.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cf5a4db6dca263010e2c7bff081c89383c72d187ba2cf4c44759aac970e2f0c4", size = 14457059, upload-time = "2026-04-21T17:06:14.935Z" }, + { url = "https://files.pythonhosted.org/packages/c9/34/417ee60b822cc80c0f3dc9f495ad7fd8dbb8d8b2cf4baf22d4046d25d01d/mypy-1.20.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7b0e817b518bff7facd7f85ea05b643ad8bdcce684cf29784987b0a7c8e1f997", size = 13346816, upload-time = "2026-04-21T17:10:41.433Z" }, + { url = "https://files.pythonhosted.org/packages/4a/85/e20951978702df58379d0bcc2e8f7ccdca4e78cd7dc66dd3ddbf9b29d517/mypy-1.20.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97d7b9a485b40f8ca425460e89bf1da2814625b2da627c0dcc6aa46c92631d14", size = 13772593, upload-time = "2026-04-21T17:08:11.24Z" }, + { url = "https://files.pythonhosted.org/packages/63/a5/5441a13259ec516c56fd5de0fd96a69a9590ae6c5e5d3e5174aa84b97973/mypy-1.20.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e1c12f6d2db3d78b909b5f77513c11eb7f2dd2782b96a3ab6dffc7d44575c99", size = 14656635, upload-time = "2026-04-21T17:09:54.042Z" }, + { url = "https://files.pythonhosted.org/packages/3b/51/b89c69157c5e1f19fd125a65d991166a26906e7902f026f00feebbcfa2b9/mypy-1.20.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:89dce27e142d25ffbc154c1819383b69f2e9234dc4ed4766f42e0e8cb264ab5c", size = 14943278, upload-time = "2026-04-21T17:09:15.599Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/6b0eeecfe96d7cce1d71c66b8e03cb304aa70ec11f1955dc1d6b46aca3c3/mypy-1.20.2-cp310-cp310-win_amd64.whl", hash = "sha256:f376e37f9bf2a946872fc5fd1199c99310748e3c26c7a26683f13f8bdb756cbd", size = 10851915, upload-time = "2026-04-21T17:06:03.5Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/6593dc88545d75fb96416184be5392da5e2a8e8c2802a8597913e16ae25c/mypy-1.20.2-cp310-cp310-win_arm64.whl", hash = "sha256:6e2b469efd811707bc530fd1effef0f5d6eebcb7fe376affae69025da4b979a2", size = 9786676, upload-time = "2026-04-21T17:07:02.035Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4d/9ebeae211caccbdaddde7ed5e31dfcf57faac66be9b11deb1dc6526c8078/mypy-1.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4077797a273e56e8843d001e9dfe4ba10e33323d6ade647ff260e5cd97d9758c", size = 14371307, upload-time = "2026-04-21T17:08:56.442Z" }, + { url = "https://files.pythonhosted.org/packages/95/d7/93473d34b61f04fac1aecc01368485c89c5c4af7a4b9a0cab5d77d04b63f/mypy-1.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cdecf62abcc4292500d7858aeae87a1f8f1150f4c4dd08fb0b336ee79b2a6df3", size = 13258917, upload-time = "2026-04-21T17:05:50.978Z" }, + { url = "https://files.pythonhosted.org/packages/e2/30/3dd903e8bafb7b5f7bf87fcd58f8382086dea2aa19f0a7b357f21f63071b/mypy-1.20.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c566c3a88b6ece59b3d70f65bedef17304f48eb52ff040a6a18214e1917b3254", size = 13700516, upload-time = "2026-04-21T17:11:33.161Z" }, + { url = "https://files.pythonhosted.org/packages/07/05/c61a140aba4c729ac7bc99ae26fc627c78a6e08f5b9dd319244ea71a3d7e/mypy-1.20.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0deb80d062b2479f2c87ae568f89845afc71d11bc41b04179e58165fd9f31e98", size = 14562889, upload-time = "2026-04-21T17:05:27.674Z" }, + { url = "https://files.pythonhosted.org/packages/fd/87/da78243742ffa8a36d98c3010f0d829f93d5da4e6786f1a1a6f2ad616502/mypy-1.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bba9ad231e92a3e424b3e56b65aa17704993425bba97e302c832f9466bb85bac", size = 14803844, upload-time = "2026-04-21T17:10:06.2Z" }, + { url = "https://files.pythonhosted.org/packages/37/52/10a1ddf91b40f843943a3c6db51e2df59c9e237f29d355e95eaab427461f/mypy-1.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:baf593f2765fa3a6b1ef95807dbaa3d25b594f6a52adcc506a6b9cb115e1be67", size = 10846300, upload-time = "2026-04-21T17:12:23.886Z" }, + { url = "https://files.pythonhosted.org/packages/20/02/f9a4415b664c53bd34d6709be59da303abcae986dc4ac847b402edb6fa1e/mypy-1.20.2-cp311-cp311-win_arm64.whl", hash = "sha256:20175a1c0f49863946ec20b7f63255768058ac4f07d2b9ded6a6b46cfb5a9100", size = 9779498, upload-time = "2026-04-21T17:09:23.695Z" }, + { url = "https://files.pythonhosted.org/packages/71/4e/7560e4528db9e9b147e4c0f22660466bf30a0a1fe3d63d1b9d3b0fd354ee/mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b", size = 14539393, upload-time = "2026-04-21T17:07:12.52Z" }, + { url = "https://files.pythonhosted.org/packages/32/d9/34a5efed8124f5a9234f55ac6a4ced4201e2c5b81e1109c49ad23190ec8c/mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4", size = 13361642, upload-time = "2026-04-21T17:06:53.742Z" }, + { url = "https://files.pythonhosted.org/packages/d1/14/eb377acf78c03c92d566a1510cda8137348215b5335085ef662ab82ecd3a/mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6", size = 13740347, upload-time = "2026-04-21T17:12:04.73Z" }, + { url = "https://files.pythonhosted.org/packages/b9/94/7e4634a32b641aa1c112422eed1bbece61ee16205f674190e8b536f884de/mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066", size = 14734042, upload-time = "2026-04-21T17:07:43.16Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f3/f7e62395cb7f434541b4491a01149a4439e28ace4c0c632bbf5431e92d1f/mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102", size = 14964958, upload-time = "2026-04-21T17:11:00.665Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0d/47e3c3a0ec2a876e35aeac365df3cac7776c36bbd4ed18cc521e1b9d255b/mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9", size = 10911340, upload-time = "2026-04-21T17:10:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/d6/b2/6c852d72e0ea8b01f49da817fb52539993cde327e7d010e0103dc12d0dac/mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58", size = 9833947, upload-time = "2026-04-21T17:09:05.267Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c4/b93812d3a192c9bcf5df405bd2f30277cd0e48106a14d1023c7f6ed6e39b/mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026", size = 14524670, upload-time = "2026-04-21T17:10:30.737Z" }, + { url = "https://files.pythonhosted.org/packages/f3/47/42c122501bff18eaf1e8f457f5c017933452d8acdc52918a9f59f6812955/mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943", size = 13336218, upload-time = "2026-04-21T17:08:44.069Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/75bbc92f41725fbd585fb17b440b1119b576105df1013622983e18640a93/mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517", size = 13724906, upload-time = "2026-04-21T17:08:01.02Z" }, + { url = "https://files.pythonhosted.org/packages/a1/32/4c49da27a606167391ff0c39aa955707a00edc500572e562f7c36c08a71f/mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15", size = 14726046, upload-time = "2026-04-21T17:11:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/7f/fc/4e354a1bd70216359deb0c9c54847ee6b32ef78dfb09f5131ff99b494078/mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee", size = 14955587, upload-time = "2026-04-21T17:12:16.033Z" }, + { url = "https://files.pythonhosted.org/packages/62/b2/c0f2056e9eb8f08c62cafd9715e4584b89132bdc832fcf85d27d07b5f3e5/mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f", size = 10922681, upload-time = "2026-04-21T17:06:35.842Z" }, + { url = "https://files.pythonhosted.org/packages/e5/14/065e333721f05de8ef683d0aa804c23026bcc287446b61cac657b902ccac/mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330", size = 9830560, upload-time = "2026-04-21T17:07:51.023Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/78/449cb84790bd5cc3823b2652ee405a4558856e5c4195aee3a16bf7b3eb5d/ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b", size = 4938814, upload-time = "2026-09-16T15:54:46.688Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/25/6071aabc530e9be7e2c195e8fe3f7aea2735405b6cf447212832d7811831/ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38", size = 10048966, upload-time = "2026-09-16T15:53:57.605Z" }, + { url = "https://files.pythonhosted.org/packages/54/98/07f90ecbc74dd5fb5764f11f2bc774d6a7cffef92d2ff5f5b4e9e23c754e/ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e", size = 10165498, upload-time = "2026-09-16T15:54:01.14Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1f/e6a712e3b47cad4a40600134105ed193cb773f618a42eb7ba323cb812cc0/ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170", size = 9830004, upload-time = "2026-09-16T15:54:03.998Z" }, + { url = "https://files.pythonhosted.org/packages/23/f2/311a08776d75d81c7676e20b6b020ae63cbe881fcdc7a8dd64e6e18bdd93/ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659", size = 9986558, upload-time = "2026-09-16T15:54:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ed/37b6cb3d3ba8c73e68ae3eb1d502383beb5aa05a582bb7bb3a922f929f54/ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2", size = 9877332, upload-time = "2026-09-16T15:54:09.552Z" }, + { url = "https://files.pythonhosted.org/packages/22/cc/40873a8f36ad084cc540d55fcca7077264d5b13b24659e9180c176fb2b08/ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812", size = 10507125, upload-time = "2026-09-16T15:54:12.152Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e4/fc91a642b78ccbab6b9477720f3644ae7a10a9bcce69a934679cd64f62bc/ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b", size = 11336694, upload-time = "2026-09-16T15:54:15.489Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/bbd2a9a600a4e73dc3e7548a249c8d1671273464b55822c6fae50f602dff/ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f", size = 10774448, upload-time = "2026-09-16T15:54:18.16Z" }, + { url = "https://files.pythonhosted.org/packages/1a/41/d83af9879a7b6e8bf5fe16b1da0b134049d2f5d3afac12defb0897cb84bd/ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df", size = 10323796, upload-time = "2026-09-16T15:54:20.743Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/cefd07bfe914b84943ea769ade8d607bd22750b965d3228eefd7cebd15d0/ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e", size = 10514115, upload-time = "2026-09-16T15:54:23.497Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9d/76a2e26c79a23be6e6e3664c57bec9e9fc8de155cfb9e4b67ea91b64f9d7/ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f", size = 10072582, upload-time = "2026-09-16T15:54:26.185Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d4/f42edddb39668af1a559ceafa3823aedd65633a48dc9768e775485faa2c1/ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2", size = 9879644, upload-time = "2026-09-16T15:54:29.278Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/913e3195d95e0378786c6656945c865f534a3560e29139da4882aff630d1/ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9", size = 10231569, upload-time = "2026-09-16T15:54:32.036Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c4/8aa6ea0bdcedbd1bf87397e2fc4ed8406448ea5842f8660bc6e5f163039d/ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773", size = 10663666, upload-time = "2026-09-16T15:54:34.838Z" }, + { url = "https://files.pythonhosted.org/packages/3d/02/7f10ef4700bc223c30a3fdd10631a29830c45524b810a3c7ed947af64591/ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f", size = 10093472, upload-time = "2026-09-16T15:54:37.47Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5d/a509c07d714b6da88f2c518b4637cf6f1d46b074be8f0f1e5fb9ff5126fe/ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa", size = 10586899, upload-time = "2026-09-16T15:54:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] diff --git a/plugins/foundation/darrow-decisions/bin/decision b/plugins/foundation/darrow-decisions/bin/decision deleted file mode 100755 index fe63dc85..00000000 --- a/plugins/foundation/darrow-decisions/bin/decision +++ /dev/null @@ -1,1173 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -export LC_ALL=C - -usage() { - cat >&2 <<'USAGE' -usage: decision inspect [--dir <path>] [--repo <path>] - decision list [--dir <path>] [--repo <path>] - [--status Proposed|Accepted|Rejected|Deprecated|Superseded] - [--search <text>] [--related-to <ADR-NNNN>] [--limit <n>] - decision next-id [--dir <path>] [--repo <path>] [--title <title>] - decision validate [--dir <path>] [--repo <path>] - decision catalog rebuild|check [--dir <path>] [--repo <path>] - decision canonical-path --path <path> [--repo <path>] - decision check-transition --from <status> --to <status> -USAGE - exit 2 -} - -CATALOG_NAME=README.md -CATALOG_FORMAT=darrow-adr-catalog-v2 - -die() { - printf 'error: %s\n' "$1" >&2 - exit "${2:-2}" -} - -status_is_valid() { - case "$1" in - Proposed|Accepted|Rejected|Deprecated|Superseded) return 0 ;; - *) return 1 ;; - esac -} - -has_control_char() { - local LC_ALL=C - [[ $1 == *[[:cntrl:]]* ]] -} - -check_transition() { - local from=$1 to=$2 - status_is_valid "$from" || die "unsupported decision status: $from" - status_is_valid "$to" || die "unsupported decision status: $to" - case "$from:$to" in - Proposed:Accepted|Proposed:Rejected|Accepted:Deprecated|Accepted:Superseded) - printf '%s -> %s: allowed\n' "$from" "$to" - ;; - *) die "unsupported decision transition: $from -> $to" 3 ;; - esac -} - -resolve_root() { - local target=$1 worktrees line - if [[ ! -d "$target" || ! -r "$target" ]]; then - die "repository path is not a readable directory: $target" - fi - if ! worktrees=$(git -C "$target" worktree list --porcelain 2>/dev/null); then - die "not a Git repository: $target" - fi - if ! line=$(awk 'NR == 1 && $1 == "worktree" {sub(/^worktree /, ""); print}' <<< "$worktrees"); then - die "cannot parse git worktree metadata" - fi - [[ -n "$line" ]] || die "cannot resolve repository root from git worktree metadata" - has_control_char "$line" && die "repository root contains unsupported control bytes" - printf '%s' "$line" -} - -inside_root() { - case "$1" in - "$ROOT"|"$ROOT"/*) return 0 ;; - *) return 1 ;; - esac -} - -path_has_symlink_component() { - local path=$1 current="" rest component - rest=${path#/} - while [[ -n "$rest" ]]; do - component=${rest%%/*} - case "$rest" in - */*) rest=${rest#*/} ;; - *) rest="" ;; - esac - [[ -n "$component" ]] || continue - current="$current/$component" - [[ ! -L "$current" ]] || return 0 - done - return 1 -} - -canonical_directory() { - local value=$1 candidate resolved - case "$value" in - /*) candidate=$value ;; - *) candidate="$ROOT/$value" ;; - esac - ! path_has_symlink_component "$candidate" || die "ADR directory symlinks are not allowed: $candidate" - [[ -d "$candidate" ]] || die "ADR directory does not exist: $candidate" - [[ -r "$candidate" && -x "$candidate" ]] || die "ADR directory is not readable: $candidate" - resolved=$(cd "$candidate" 2>/dev/null && pwd -P) || die "cannot resolve ADR directory: $candidate" - has_control_char "$resolved" && die "ADR directory contains unsupported control bytes" - inside_root "$resolved" || die "ADR directory escapes the repository root: $resolved" - printf '%s' "$resolved" -} - -run_canonical_path() { - local candidate parent name resolved_parent resolved - [[ -n "$PATH_ARG" ]] || usage - case "$PATH_ARG" in - /*) candidate=$PATH_ARG ;; - *) candidate="$ROOT/$PATH_ARG" ;; - esac - ! path_has_symlink_component "$candidate" || die "canonical record symlinks are not allowed: $candidate" - [[ -f "$candidate" ]] || die "canonical record is not a regular file: $candidate" - [[ -r "$candidate" ]] || die "canonical record is not readable: $candidate" - parent=$(dirname "$candidate") || die "cannot resolve canonical record parent: $candidate" - name=$(basename "$candidate") || die "cannot resolve canonical record name: $candidate" - resolved_parent=$(cd "$parent" 2>/dev/null && pwd -P) || die "cannot resolve canonical record parent: $candidate" - resolved="$resolved_parent/$name" - has_control_char "$resolved" && die "canonical record path contains unsupported control bytes" - inside_root "$resolved" || die "canonical record escapes the repository root: $resolved" - printf 'path: %s\n' "$resolved" -} - -select_adr_directory() { - local candidate duplicate existing - ADR_DIR="" - if [[ -n "$DIR_ARG" ]]; then - ADR_DIR=$(canonical_directory "$DIR_ARG") - return - fi - - ADR_DIR_COUNT=0 - ADR_DIR_CANDIDATES=() - for candidate in \ - "$ROOT/docs/decisions" \ - "$ROOT/docs/adrs" \ - "$ROOT/docs/adr" \ - "$ROOT/architecture/decisions" \ - "$ROOT/adrs" \ - "$ROOT/adr" - do - if [[ ( -e "$candidate" || -L "$candidate" ) && ! -d "$candidate" ]]; then - die "ADR directory candidate is not a directory: $candidate" - fi - if [[ -d "$candidate" ]]; then - candidate=$(canonical_directory "$candidate") - duplicate=0 - for existing in "${ADR_DIR_CANDIDATES[@]:-}"; do - [[ "$existing" != "$candidate" ]] || duplicate=1 - done - if [[ $duplicate -eq 0 ]]; then - ADR_DIR_CANDIDATES+=("$candidate") - ADR_DIR_COUNT=$((ADR_DIR_COUNT + 1)) - fi - fi - done - - if [[ $ADR_DIR_COUNT -eq 1 ]]; then - ADR_DIR=${ADR_DIR_CANDIDATES[0]} - elif [[ $ADR_DIR_COUNT -gt 1 ]]; then - printf 'error: multiple ADR directories found; pass --dir explicitly:\n' >&2 - for candidate in "${ADR_DIR_CANDIDATES[@]}"; do - printf ' - %s\n' "$candidate" >&2 - done - exit 3 - fi -} - -valid_iso_date() { - local value=$1 year month day year_n month_n day_n max_day - [[ $value =~ ^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$ ]] || return 1 - year=${value:0:4} - month=${value:5:2} - day=${value:8:2} - year_n=$((10#$year)) - month_n=$((10#$month)) - day_n=$((10#$day)) - [[ $year_n -gt 0 && $month_n -ge 1 && $month_n -le 12 && $day_n -ge 1 ]] || return 1 - case "$month_n" in - 1|3|5|7|8|10|12) max_day=31 ;; - 4|6|9|11) max_day=30 ;; - 2) - max_day=28 - if (( year_n % 400 == 0 || (year_n % 4 == 0 && year_n % 100 != 0) )); then - max_day=29 - fi - ;; - esac - [[ $day_n -le $max_day ]] -} - -parse_adr() { - local file=$1 - LC_ALL=C awk ' - function trim(value) { - sub(/^[ \t]+/, "", value) - sub(/[ \t]+$/, "", value) - return value - } - function clean(value) { - gsub(/[[:cntrl:]]/, " ", value) - return value - } - function heading_info(value, level, rest, text) { - level = 0 - while (substr(value, level + 1, 1) == "#") level++ - if (level < 1 || level > 6) return 0 - rest = substr(value, level + 1, 1) - if (rest != " " && rest != "\t") return 0 - text = trim(substr(value, level + 2)) - sub(/[ \t]+#+[ \t]*$/, "", text) - heading_level = level - heading_text = text - return 1 - } - BEGIN { - sep = sprintf("%c", 28) - in_fence = 0 - fence_char = "" - fence_size = 0 - metadata = 1 - section = "" - } - { - line = $0 - if (NR == 1) sub(/^\357\273\277/, "", line) - scan = line - indent = 0 - while (indent < 3 && substr(scan, 1, 1) == " ") { - scan = substr(scan, 2) - indent++ - } - marker = substr(scan, 1, 1) - marker_size = 0 - if (marker == "`" || marker == "~") { - while (substr(scan, marker_size + 1, 1) == marker) marker_size++ - } - if (marker_size >= 3) { - if (!in_fence) { - in_fence = 1 - fence_char = marker - fence_size = marker_size - } else if (marker == fence_char && marker_size >= fence_size && trim(substr(scan, marker_size + 1)) == "") { - in_fence = 0 - fence_char = "" - fence_size = 0 - } else if (section != "" && trim(line) != "") { - nonempty[section] = 1 - } - next - } - if (in_fence) { - if (section != "" && trim(line) != "") nonempty[section] = 1 - next - } - - heading_level = 0 - heading_text = "" - if (heading_info(scan)) { - if (heading_level == 1 && heading_text ~ /^ADR-/) { - heading_count++ - colon = index(heading_text, ":") - if (colon > 0) { - identifier = trim(substr(heading_text, 1, colon - 1)) - title = trim(substr(heading_text, colon + 1)) - } else { - identifier = trim(heading_text) - title = "" - } - } - if (heading_level <= 2) section = "" - if (heading_level == 2) { - metadata = 0 - if (heading_text == "Context") { - section = "context" - context_count++ - } else if (heading_text == "Decision") { - section = "decision" - decision_count++ - } else if (heading_text == "Consequences") { - section = "consequences" - consequences_count++ - } - } - next - } - - if (metadata && line ~ /^Status:[ \t]*/) { - status_count++ - status = trim(substr(line, index(line, ":") + 1)) - next - } - if (metadata && line ~ /^Date:[ \t]*/) { - date_count++ - date = trim(substr(line, index(line, ":") + 1)) - next - } - if (metadata && line ~ /^Summary:[ \t]*/) { - summary_count++ - summary = trim(substr(line, index(line, ":") + 1)) - next - } - if (metadata && line ~ /^Supersedes:[ \t]*/) { - supersedes_count++ - supersedes = trim(substr(line, index(line, ":") + 1)) - next - } - if (metadata && line ~ /^Superseded by:[ \t]*/) { - superseded_by_count++ - superseded_by = trim(substr(line, index(line, ":") + 1)) - next - } - if (metadata && line ~ /^Revisit when:[ \t]*/) { - revisit_count++ - revisit = trim(substr(line, index(line, ":") + 1)) - next - } - if (section != "" && trim(line) != "") nonempty[section] = 1 - } - END { - printf "%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s", \ - clean(identifier), sep, clean(title), sep, clean(status), sep, clean(date), sep, clean(summary), sep, \ - clean(supersedes), sep, clean(superseded_by), sep, clean(revisit), sep, \ - heading_count + 0, sep, status_count + 0, sep, date_count + 0, sep, summary_count + 0, sep, \ - supersedes_count + 0, sep, superseded_by_count + 0, sep, revisit_count + 0, sep, \ - context_count + 0, sep, decision_count + 0, sep, consequences_count + 0, sep, \ - nonempty["context"] + 0, sep, nonempty["decision"] + 0, sep, nonempty["consequences"] + 0, sep, in_fence + 0 - } - ' "$file" -} - -relative_to_root() { - case "$1" in - "$ROOT"/*) printf '%s' "${1#"$ROOT"/}" ;; - *) die "path is outside the repository root: $1" ;; - esac -} - -current_fingerprint() { - local file=$1 fingerprint - # The parser consumes worktree bytes. Clean filters and refreshed index - # entries can equate different metadata, so neither proves those bytes. - fingerprint=$(git -C "$ROOT" hash-object --no-filters "$file" 2>/dev/null) || die "cannot fingerprint ADR file: $file" - [[ $fingerprint =~ ^[0-9a-f]+$ ]] || die "cannot fingerprint ADR file: $file" - printf '%s' "$fingerprint" -} - -markdown_cell() { - LC_ALL=C sed 's/&/\&/g; s/</\</g; s/>/\>/g; s/\\/\\\\/g; s/|/\\|/g' <<< "$1" -} - -markdown_label() { - LC_ALL=C sed 's/&/\&/g; s/</\</g; s/>/\>/g; s/\\/\\\\/g; s/\[/\\[/g; s/\]/\\]/g; s/|/\\|/g' <<< "$1" -} - -markdown_target() { - LC_ALL=C sed 's/%/%25/g; s/\\/%5C/g; s/ /%20/g; s/(/%28/g; s/)/%29/g; s/#/%23/g; s/?/%3F/g; s/|/%7C/g; s/</%3C/g; s/>/%3E/g' <<< "$1" -} - -catalog_relationships() { - local supersedes=$1 superseded_by=$2 revisit=$3 rendered="" - [[ -z "$supersedes" ]] || rendered="Supersedes: $supersedes" - if [[ -n "$superseded_by" ]]; then - [[ -z "$rendered" ]] || rendered="$rendered; " - rendered="${rendered}Superseded by: $superseded_by" - fi - if [[ -n "$revisit" ]]; then - [[ -z "$rendered" ]] || rendered="$rendered; " - rendered="${rendered}Revisit when: $revisit" - fi - printf '%s' "${rendered:-None}" -} - -write_catalog_file() { - local target=$1 wanted identifier title status date summary supersedes superseded_by revisit file - local fingerprint basename label link summary_cell relationships relationship_cell signature crc bytes source_file source_name - { - printf '# ADR Catalog\n\n' - printf 'This checked-in catalog is derived, non-authoritative metadata. ADR files remain canonical.\n' - # shellcheck disable=SC2016 # literal Markdown code spans, not command substitution - printf 'Rebuild it with `decision catalog rebuild` and verify it with `decision catalog check`.\n\n' - printf '<!-- %s -->\n' "$CATALOG_FORMAT" - } > "$target" - source_file="$DECISION_TMP/catalog-sources" - for wanted in Proposed Accepted Rejected Deprecated Superseded; do - : > "$source_file" - { - printf '\n## %s\n\n' "$wanted" - printf '<!-- prettier-ignore -->\n' - printf '| Decision | Status | Date | Summary | Relationships |\n' - printf '| --- | --- | --- | --- | --- |\n' - } >> "$target" - while IFS=$'\034' read -r identifier title status date summary supersedes superseded_by revisit file; do - [[ -n "$identifier" && "$status" == "$wanted" ]] || continue - fingerprint=$(current_fingerprint "$file") - basename=${file##*/} - source_name=$(markdown_target "$basename") - label=$(markdown_label "$identifier: $title") - link=$(markdown_target "$basename") - summary_cell=$(markdown_cell "$summary") - relationships=$(catalog_relationships "$supersedes" "$superseded_by" "$revisit") - relationship_cell=$(markdown_cell "$relationships") - printf '| [%s](%s) | %s | %s | %s | %s |\n' \ - "$label" "$link" "$status" "$date" "$summary_cell" "$relationship_cell" >> "$target" - printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ - "$fingerprint" "$basename" "$link" "$identifier" "$title" "$status" "$date" "$summary" "$supersedes" "$superseded_by" "$revisit" > "$DECISION_TMP/catalog-signature" - signature=$(cksum < "$DECISION_TMP/catalog-signature") || die "cannot checksum ADR catalog row: $file" - crc=${signature%% *}; signature=${signature#* }; bytes=${signature%% *} - [[ $crc =~ ^[0-9]+$ && $bytes =~ ^[0-9]+$ ]] || die "cannot parse ADR catalog row checksum: $file" - printf '<!-- darrow-source: %s %s %s %s -->\n' "$fingerprint" "$crc" "$bytes" "$source_name" >> "$source_file" - done < "$INVENTORY" - if [[ -s "$source_file" ]]; then - printf '\n' >> "$target" - cat "$source_file" >> "$target" - fi - done -} - -filename_identifier() { - local base - base=${1##*/} - if [[ $base =~ ^(ADR-[0-9]+)(-|\.md$) ]]; then - printf '%s' "${BASH_REMATCH[1]}" - return 0 - fi - return 1 -} - -record_error() { - ERROR_COUNT=$((ERROR_COUNT + 1)) - if [[ $ERROR_COUNT -le 40 ]]; then - printf 'error: %s\n' "$1" >> "$ERRORS" - fi -} - -relation_contains() { - local rest=$1 wanted=$2 item - while :; do - item=${rest%%,*} - item=${item#"${item%%[![:space:]]*}"} - item=${item%"${item##*[![:space:]]}"} - [[ "$item" != "$wanted" ]] || return 0 - [[ "$rest" == *,* ]] || break - rest=${rest#*,} - done - return 1 -} - -build_inventory() { - local file parsed file_id identifier title status date summary supersedes superseded_by revisit - local heading_count status_count date_count summary_count supersedes_count superseded_by_count revisit_count - local context_count decision_count consequences_count context_nonempty decision_nonempty consequences_nonempty unclosed_fence - local unsafe_file_count - - : > "$INVENTORY" - : > "$ERRORS" - ERROR_COUNT=0 - [[ -n "$ADR_DIR" ]] || return 0 - - if ! find "$ADR_DIR" -maxdepth 1 -mindepth 1 -name '*.md' -print0 > "$DECISION_TMP/files"; then - record_error "$ADR_DIR: cannot enumerate Markdown entries" - return 1 - fi - : > "$DECISION_TMP/safe-files" - unsafe_file_count=0 - while IFS= read -r -d '' file; do - if has_control_char "$file"; then - unsafe_file_count=$((unsafe_file_count + 1)) - continue - fi - printf '%s\n' "$file" >> "$DECISION_TMP/safe-files" - done < "$DECISION_TMP/files" - if ! LC_ALL=C sort "$DECISION_TMP/safe-files" -o "$DECISION_TMP/safe-files"; then - record_error "$ADR_DIR: cannot sort ADR paths" - return 1 - fi - if [[ $unsafe_file_count -gt 0 ]]; then - record_error "$ADR_DIR: $unsafe_file_count ADR filename(s) contain unsupported control bytes" - fi - while IFS= read -r file; do - [[ "$file" != "$ADR_DIR/$CATALOG_NAME" ]] || continue - if [[ -L "$file" ]]; then - record_error "$file: ADR symlinks are not allowed" - continue - fi - if [[ ! -f "$file" ]]; then - record_error "$file: ADR path is occupied by an unsupported file type" - continue - fi - if [[ ! -r "$file" ]]; then - record_error "$file: Markdown file is not readable" - continue - fi - file_id="" - if filename_identifier "$file" >/dev/null; then - file_id=$(filename_identifier "$file") - fi - if ! parsed=$(parse_adr "$file"); then - record_error "$file: cannot parse ADR file" - continue - fi - IFS=$'\034' read -r identifier title status date summary supersedes superseded_by revisit \ - heading_count status_count date_count summary_count supersedes_count superseded_by_count revisit_count \ - context_count decision_count consequences_count context_nonempty decision_nonempty consequences_nonempty unclosed_fence <<< "$parsed" - - if [[ -z "$file_id" ]]; then record_error "$file: filename does not carry its ADR identifier"; fi - if [[ $heading_count -ne 1 ]]; then record_error "$file: expected exactly one level-1 ADR heading; found $heading_count"; fi - if [[ -z "$identifier" ]]; then - record_error "$file: ADR heading has no identifier" - elif [[ ${#identifier} -gt 22 ]]; then - record_error "$file: ADR identifier exceeds the 18-digit limit" - elif [[ ! $identifier =~ ^ADR-[0-9][0-9][0-9][0-9][0-9]*$ ]]; then - record_error "$file: unsupported ADR identifier: $identifier" - fi - if [[ -n "$file_id" && -n "$identifier" && "$file_id" != "$identifier" ]]; then - record_error "$file: filename identifier $file_id disagrees with heading identifier $identifier" - fi - if [[ -z "$title" ]]; then - record_error "$file: ADR title is empty" - elif [[ ${#title} -gt 240 ]]; then - record_error "$file: ADR title exceeds 240 bytes" - fi - if [[ $status_count -ne 1 ]]; then - record_error "$file: expected exactly one Status field; found $status_count" - elif [[ ${#status} -gt 32 ]]; then - record_error "$file: decision status exceeds 32 bytes" - elif ! status_is_valid "$status"; then - record_error "$file: unsupported decision status: $status" - fi - if [[ $date_count -ne 1 ]]; then - record_error "$file: expected exactly one Date field; found $date_count" - elif [[ ${#date} -gt 32 ]]; then - record_error "$file: Date exceeds 32 bytes" - elif ! valid_iso_date "$date"; then - record_error "$file: Date is not a valid ISO calendar date: $date" - fi - if [[ $summary_count -ne 1 ]]; then - record_error "$file: expected exactly one Summary field; found $summary_count" - elif [[ -z "$summary" ]]; then - record_error "$file: Summary field is blank" - elif [[ ${#summary} -gt 500 ]]; then - record_error "$file: Summary exceeds 500 bytes" - fi - [[ $supersedes_count -le 1 ]] || record_error "$file: duplicate Supersedes fields" - [[ $superseded_by_count -le 1 ]] || record_error "$file: duplicate Superseded by fields" - [[ $revisit_count -le 1 ]] || record_error "$file: duplicate Revisit when fields" - [[ $supersedes_count -ne 1 || -n "$supersedes" ]] || record_error "$file: Supersedes field is blank" - [[ $superseded_by_count -ne 1 || -n "$superseded_by" ]] || record_error "$file: Superseded by field is blank" - [[ $revisit_count -ne 1 || -n "$revisit" ]] || record_error "$file: Revisit when field is blank" - [[ ${#supersedes} -le 1000 ]] || record_error "$file: Supersedes exceeds 1000 bytes" - [[ ${#superseded_by} -le 1000 ]] || record_error "$file: Superseded by exceeds 1000 bytes" - [[ ${#revisit} -le 500 ]] || record_error "$file: Revisit when exceeds 500 bytes" - [[ $unclosed_fence -eq 0 ]] || record_error "$file: Markdown fence is not closed" - if [[ $context_count -ne 1 || $context_nonempty -ne 1 ]]; then - record_error "$file: Context section must occur once and contain content" - fi - if [[ $decision_count -ne 1 || $decision_nonempty -ne 1 ]]; then - record_error "$file: Decision section must occur once and contain content" - fi - if [[ $consequences_count -ne 1 || $consequences_nonempty -ne 1 ]]; then - record_error "$file: Consequences section must occur once and contain content" - fi - - printf '%s\034%s\034%s\034%s\034%s\034%s\034%s\034%s\034%s\n' \ - "$identifier" "$title" "$status" "$date" "$summary" "$supersedes" "$superseded_by" "$revisit" "$file" >> "$INVENTORY" - done < "$DECISION_TMP/safe-files" - - if ! LC_ALL=C sort "$INVENTORY" -o "$INVENTORY"; then - record_error "$ADR_DIR: cannot sort ADR inventory" - return 1 - fi - if ! DECISION_SEP=$SEP awk ' - function trim(value) { - sub(/^[ \t]+/, "", value) - sub(/[ \t]+$/, "", value) - return value - } - function valid_id(value, digits) { - if (substr(value, 1, 4) != "ADR-") return 0 - digits = substr(value, 5) - return length(digits) >= 4 && length(digits) <= 18 && digits !~ /[^0-9]/ - } - function contains(value, wanted, count, part, i) { - count = split(value, part, ",") - for (i = 1; i <= count; i++) if (trim(part[i]) == wanted) return 1 - return 0 - } - function report(message) {print message} - function was_accepted(value) { - return value == "Accepted" || value == "Deprecated" || value == "Superseded" - } - function validate_relations(id, value, kind, count, part, seen, i, target) { - if (value == "") return - count = split(value, part, ",") - if (count > 50) report(path[id] ": " kind " has more than 50 targets") - for (i = 1; i <= count; i++) { - target = trim(part[i]) - if (!valid_id(target)) { - report(path[id] ": invalid " kind " identifier") - continue - } - if (seen[target]++) { - report(path[id] ": duplicate " kind " target: " target) - continue - } - if (target == id) { - report(path[id] ": an ADR cannot relate to itself through " kind) - continue - } - if (!(target in present)) { - report(path[id] ": " kind " target does not exist: " target) - continue - } - if (kind == "Supersedes") { - edge[id, ++edge_count[id]] = target - if (status[target] != "Superseded") report(path[id] ": " target " must have status Superseded") - if (!contains(superseded_by[target], id)) report(path[id] ": " target " does not reciprocate Superseded by " id) - } else { - if (!was_accepted(status[target])) report(path[id] ": replacement " target " must have status Accepted, Deprecated, or Superseded") - if (!contains(supersedes[target], id)) report(path[id] ": " target " does not reciprocate Supersedes " id) - } - } - } - function visit(id, i, target) { - state[id] = 1 - for (i = 1; i <= edge_count[id]; i++) { - target = edge[id, i] - if (state[target] == 1) { - report(path[id] ": supersession relationship cycle detected at edge " id " -> " target) - return 1 - } - if (state[target] == 0 && visit(target)) return 1 - } - state[id] = 2 - return 0 - } - BEGIN {FS = ENVIRON["DECISION_SEP"]} - $1 != "" { - id = $1 - if (id in present) { - report("duplicate ADR identifier: " id) - next - } - present[id] = 1 - ids[++record_count] = id - status[id] = $3 - supersedes[id] = $6 - superseded_by[id] = $7 - path[id] = $9 - } - END { - for (record = 1; record <= record_count; record++) { - id = ids[record] - if (supersedes[id] != "" && !was_accepted(status[id])) report(path[id] ": Supersedes is allowed only on an Accepted, Deprecated, or Superseded replacement") - if (superseded_by[id] != "" && status[id] != "Superseded") report(path[id] ": Superseded by requires status Superseded") - if (superseded_by[id] == "" && status[id] == "Superseded") report(path[id] ": status Superseded requires Superseded by") - validate_relations(id, supersedes[id], "Supersedes") - validate_relations(id, superseded_by[id], "Superseded by") - } - for (record = 1; record <= record_count; record++) { - id = ids[record] - if (state[id] == 0 && visit(id)) break - } - } - ' "$INVENTORY" > "$DECISION_TMP/relationship-errors"; then - record_error "$ADR_DIR: cannot validate ADR relationships" - return 1 - fi - while IFS= read -r parsed; do - [[ -z "$parsed" ]] || record_error "$parsed" - done < "$DECISION_TMP/relationship-errors" - - if [[ $ERROR_COUNT -gt 40 ]]; then - printf 'error: %d additional diagnostics omitted\n' "$((ERROR_COUNT - 40))" >> "$ERRORS" - fi - [[ $ERROR_COUNT -eq 0 ]] -} - -parse_catalog_rows() { - local catalog=$1 - CATALOG_ROWS="$DECISION_TMP/catalog-rows" - CATALOG_NAMES="$DECISION_TMP/catalog-names" - : > "$CATALOG_ROWS" - if ! DECISION_SEP=$SEP LC_ALL=C awk ' - function trim(value) {sub(/^[ \t]+/, "", value); sub(/[ \t]+$/, "", value); return value} - function fail() {bad = 1} - function encode_target(value) { - gsub(/%/, "%25", value); gsub(/\\/, "%5C", value); gsub(/ /, "%20", value) - gsub(/\(/, "%28", value); gsub(/\)/, "%29", value); gsub(/#/, "%23", value) - gsub(/\?/, "%3F", value); gsub(/\|/, "%7C", value); gsub(/</, "%3C", value); gsub(/>/, "%3E", value) - return value - } - function decode_target(value) { - gsub(/%3E/, ">", value); gsub(/%3C/, "<", value); gsub(/%7C/, "|", value) - gsub(/%3F/, "?", value); gsub(/%23/, "#", value); gsub(/%29/, ")", value) - gsub(/%28/, "(", value); gsub(/%20/, " ", value); gsub(/%5C/, "\\", value) - gsub(/%25/, "%", value) - return value - } - function decode_html(value) { - gsub(/</, "<", value); gsub(/>/, ">", value); gsub(/&/, "\\&", value) - return value - } - function split_cells(line, out, i, c, escaped, value, count) { - value = ""; count = 0 - for (i = 2; i < length(line); i++) { - c = substr(line, i, 1) - if (escaped) {value = value c; escaped = 0} - else if (c == "\\") escaped = 1 - else if (c == "|") {out[++count] = trim(value); value = ""} - else value = value c - } - if (escaped) value = value "\\" - out[++count] = trim(value) - return count - } - function parse_relationships(value, rest, pos) { - supersedes = ""; superseded_by = ""; revisit = "" - if (value == "None") return 1 - rest = value - if (substr(rest, 1, 12) == "Supersedes: ") { - rest = substr(rest, 13); pos = index(rest, "; ") - if (pos) {supersedes = substr(rest, 1, pos - 1); rest = substr(rest, pos + 2)} - else {supersedes = rest; rest = ""} - } - if (substr(rest, 1, 15) == "Superseded by: ") { - rest = substr(rest, 16); pos = index(rest, "; ") - if (pos) {superseded_by = substr(rest, 1, pos - 1); rest = substr(rest, pos + 2)} - else {superseded_by = rest; rest = ""} - } - if (substr(rest, 1, 14) == "Revisit when: ") {revisit = substr(rest, 15); rest = ""} - return rest == "" && (supersedes != "" || superseded_by != "" || revisit != "") - } - /^# ADR Catalog$/ {heading++; next} - /^This checked-in catalog is derived, non-authoritative metadata\. ADR files remain canonical\.$/ {description++; next} - /^Rebuild it with `decision catalog rebuild` and verify it with `decision catalog check`\.$/ {instructions++; next} - /^<!-- darrow-adr-catalog-v2 -->$/ {format++; next} - /^## / { - current = substr($0, 4) - if (current !~ /^(Proposed|Accepted|Rejected|Deprecated|Superseded)$/ || section[current]++) fail() - section_count++ - next - } - /^<!-- prettier-ignore -->$/ {prettier_markers++; next} - /^\| Decision \| Status \| Date \| Summary \| Relationships \|$/ { - if (current == "" || table_header[current]++) fail(); else header_count++ - next - } - /^\| --- \| --- \| --- \| --- \| --- \|$/ { - if (current == "" || table_separator[current]++ || table_header[current] != 1) fail(); else separator_count++ - next - } - /^\| / { - for (key in cell) delete cell[key] - if (split_cells($0, cell) != 5 || current == "" || table_header[current] != 1 || table_separator[current] != 1) {fail(); next} - linkpos = 0 - for (i = 1; i < length(cell[1]); i++) if (substr(cell[1], i, 2) == "](") linkpos = i - if (substr(cell[1], 1, 1) != "[" || linkpos == 0 || substr(cell[1], length(cell[1]), 1) != ")") {fail(); next} - label = substr(cell[1], 2, linkpos - 2) - link = substr(cell[1], linkpos + 2, length(cell[1]) - linkpos - 2) - colon = index(label, ": ") - if (colon == 0) {fail(); next} - identifier = substr(label, 1, colon - 1) - title = decode_html(substr(label, colon + 2)) - status = cell[2]; date = cell[3]; summary = decode_html(cell[4]) - if (identifier !~ /^ADR-[0-9][0-9][0-9][0-9][0-9]*$/ || title == "" || status != current || summary == "" || !parse_relationships(decode_html(cell[5]))) {fail(); next} - row_count++ - row_identifier[row_count] = identifier; row_title[row_count] = title - row_link[row_count] = link - row_status[row_count] = status; row_date[row_count] = date; row_summary[row_count] = summary - row_supersedes[row_count] = supersedes; row_superseded_by[row_count] = superseded_by; row_revisit[row_count] = revisit - next - } - /^<!-- darrow-source: / { - source_count++ - if (source_count > row_count || $0 !~ /^<!-- darrow-source: [0-9a-f]+ [0-9]+ [0-9]+ .+ -->$/) {fail(); next} - source = substr($0, 21, length($0) - 24) - space = index(source, " "); fingerprint = substr(source, 1, space - 1); source = substr(source, space + 1) - space = index(source, " "); crc = substr(source, 1, space - 1); source = substr(source, space + 1) - space = index(source, " "); bytes = substr(source, 1, space - 1); name = decode_target(substr(source, space + 1)) - identifier = row_identifier[source_count]; title = row_title[source_count]; status = row_status[source_count] - link = row_link[source_count] - date = row_date[source_count]; summary = row_summary[source_count]; supersedes = row_supersedes[source_count] - superseded_by = row_superseded_by[source_count]; revisit = row_revisit[source_count] - if ((length(fingerprint) != 40 && length(fingerprint) != 64) || fingerprint ~ /[^0-9a-f]/ || crc ~ /[^0-9]/ || bytes ~ /[^0-9]/ || name !~ /^ADR-[0-9]+.*\.md$/ || name ~ /\// || link != encode_target(name) || seen_id[identifier]++ || seen_name[name]++) {fail(); next} - print fingerprint ENVIRON["DECISION_SEP"] name ENVIRON["DECISION_SEP"] identifier ENVIRON["DECISION_SEP"] title ENVIRON["DECISION_SEP"] status ENVIRON["DECISION_SEP"] date ENVIRON["DECISION_SEP"] summary ENVIRON["DECISION_SEP"] supersedes ENVIRON["DECISION_SEP"] superseded_by ENVIRON["DECISION_SEP"] revisit ENVIRON["DECISION_SEP"] crc ENVIRON["DECISION_SEP"] bytes ENVIRON["DECISION_SEP"] link - next - } - /^[ \t]*$/ {next} - {fail()} - END { - if (heading != 1 || description != 1 || instructions != 1 || format != 1 || section_count != 5 || prettier_markers != 5 || header_count != 5 || separator_count != 5 || source_count != row_count || bad) exit 1 - } - ' "$catalog" > "$CATALOG_ROWS"; then - return 1 - fi - DECISION_SEP=$SEP LC_ALL=C awk 'BEGIN {FS = ENVIRON["DECISION_SEP"]} {print $2}' "$CATALOG_ROWS" | LC_ALL=C sort > "$CATALOG_NAMES" || return 1 -} - -collect_catalog_files() { - local file unsafe_count - CATALOG_FILES="$DECISION_TMP/catalog-files" - CATALOG_ACTUAL_NAMES="$DECISION_TMP/catalog-actual-names" - find "$ADR_DIR" -maxdepth 1 -mindepth 1 -name '*.md' -print0 > "$DECISION_TMP/catalog-files0" || return 1 - : > "$CATALOG_FILES" - unsafe_count=0 - while IFS= read -r -d '' file; do - [[ "$file" != "$CATALOG_PATH" ]] || continue - if has_control_char "$file"; then unsafe_count=$((unsafe_count + 1)); continue; fi - [[ ! -L "$file" && -f "$file" && -r "$file" ]] || return 1 - printf '%s\n' "$file" >> "$CATALOG_FILES" - done < "$DECISION_TMP/catalog-files0" - [[ $unsafe_count -eq 0 ]] || return 1 - LC_ALL=C sort "$CATALOG_FILES" -o "$CATALOG_FILES" || return 1 - awk '{name=$0; sub(/^.*\//, "", name); print name}' "$CATALOG_FILES" > "$CATALOG_ACTUAL_NAMES" || return 1 -} - -catalog_is_fresh() { - local catalog=$1 fingerprint basename identifier title status date summary supersedes superseded_by revisit crc bytes link current relative_catalog catalog_flag signature actual_crc actual_bytes - [[ -e "$catalog" || -L "$catalog" ]] || return 10 - [[ ! -L "$catalog" && -f "$catalog" ]] || return 12 - [[ -r "$catalog" ]] || return 11 - relative_catalog=$(relative_to_root "$catalog") - git -C "$ROOT" cat-file blob "HEAD:$relative_catalog" > "$DECISION_TMP/committed-catalog" 2>/dev/null || return 13 - catalog_flag=$(git -C "$ROOT" ls-files -v -- "$relative_catalog" 2>/dev/null) || return 13 - [[ "$catalog_flag" == "H $relative_catalog" ]] || return 13 - # Compare the parsed catalog itself without clean filters or index shortcuts. - cmp -s "$catalog" "$DECISION_TMP/committed-catalog" || return 13 - parse_catalog_rows "$catalog" || return 12 - collect_catalog_files || return 13 - cmp -s "$CATALOG_NAMES" "$CATALOG_ACTUAL_NAMES" || return 13 - while IFS=$'\034' read -r fingerprint basename identifier title status date summary supersedes superseded_by revisit crc bytes link; do - printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ - "$fingerprint" "$basename" "$link" "$identifier" "$title" "$status" "$date" "$summary" "$supersedes" "$superseded_by" "$revisit" > "$DECISION_TMP/catalog-signature" - signature=$(cksum < "$DECISION_TMP/catalog-signature") || return 12 - actual_crc=${signature%% *}; signature=${signature#* }; actual_bytes=${signature%% *} - [[ "$actual_crc" == "$crc" && "$actual_bytes" == "$bytes" ]] || return 12 - current=$(current_fingerprint "$ADR_DIR/$basename") || return 13 - [[ "$current" == "$fingerprint" ]] || return 13 - done < "$CATALOG_ROWS" - return 0 -} - -catalog_to_inventory() { - local fingerprint basename identifier title status date summary supersedes superseded_by revisit crc bytes link - : > "$INVENTORY" - while IFS=$'\034' read -r fingerprint basename identifier title status date summary supersedes superseded_by revisit crc bytes link; do - printf '%s\034%s\034%s\034%s\034%s\034%s\034%s\034%s\034%s\n' \ - "$identifier" "$title" "$status" "$date" "$summary" "$supersedes" "$superseded_by" "$revisit" "$ADR_DIR/$basename" >> "$INVENTORY" - done < "$CATALOG_ROWS" - LC_ALL=C sort "$INVENTORY" -o "$INVENTORY" || die "cannot sort ADR catalog inventory" -} - -warn_catalog_fallback() { - case "$1" in - 10) printf 'warning: ADR catalog is missing; using full scan: %s\n' "$CATALOG_PATH" >&2 ;; - 11) printf 'warning: ADR catalog is unreadable; using full scan: %s\n' "$CATALOG_PATH" >&2 ;; - 12) printf 'warning: ADR catalog is malformed; using full scan: %s\n' "$CATALOG_PATH" >&2 ;; - *) printf 'warning: ADR catalog is stale; using full scan: %s\n' "$CATALOG_PATH" >&2 ;; - esac -} - -catalog_matches_inventory() { - local expected="$DECISION_TMP/expected-catalog" - write_catalog_file "$expected" - cmp -s "$expected" "$CATALOG_PATH" -} - -inventory_count() { - DECISION_SEP=$SEP awk 'BEGIN {FS = ENVIRON["DECISION_SEP"]} $1 != "" {count++} END {print count + 0}' "$INVENTORY" -} - -print_surfaces() { - local path count found - echo "specification-surfaces:" - found=0 - for path in "$ROOT/docs/specs" "$ROOT/specs"; do - if [[ -e "$path" ]]; then - [[ -r "$path" ]] || die "canonical specification surface is not readable: $path" - if [[ -d "$path" ]]; then - if ! find "$path" -maxdepth 1 -type f -name '*.md' -print > "$DECISION_TMP/surface-files"; then - die "cannot enumerate canonical specification surface: $path" - fi - if ! count=$(awk 'END {print NR + 0}' "$DECISION_TMP/surface-files"); then - die "cannot summarize canonical specification surface: $path" - fi - printf ' - %s (%s Markdown files)\n' "$path" "$count" - else - printf ' - %s\n' "$path" - fi - found=1 - fi - done - [[ $found -eq 1 ]] || echo " - (none detected)" - - echo "policy-surfaces:" - found=0 - for path in "$ROOT/AGENTS.md" "$ROOT/CLAUDE.md" "$ROOT/CONTRIBUTING.md" "$ROOT/GOVERNANCE.md"; do - if [[ -e "$path" || -L "$path" ]]; then - [[ -r "$path" ]] || die "canonical policy surface is not readable: $path" - printf ' - %s\n' "$path" - found=1 - fi - done - [[ $found -eq 1 ]] || echo " - (none detected)" -} - -run_inspect() { - local count - if ! build_inventory; then cat "$ERRORS" >&2; exit 4; fi - if ! count=$(inventory_count); then die "cannot count ADR inventory"; fi - printf 'root: %s\n' "$ROOT" - if [[ -n "$ADR_DIR" ]]; then - printf 'adr-directory: %s\n' "$ADR_DIR" - else - echo "adr-directory: (none detected)" - fi - printf 'adr-records: %s\n' "$count" - print_surfaces -} - -run_validate() { - local count - if ! build_inventory; then cat "$ERRORS" >&2; exit 4; fi - if ! count=$(inventory_count); then die "cannot count ADR inventory"; fi - if [[ -n "$ADR_DIR" ]]; then - if [[ $count -gt 0 && ! -e "$CATALOG_PATH" && ! -L "$CATALOG_PATH" ]]; then - die "ADR catalog is missing: $CATALOG_PATH" 4 - fi - fi - if [[ -n "$ADR_DIR" ]] && [[ -e "$CATALOG_PATH" || -L "$CATALOG_PATH" ]]; then - [[ ! -L "$CATALOG_PATH" && -f "$CATALOG_PATH" ]] || die "ADR catalog is malformed: $CATALOG_PATH" 4 - [[ -r "$CATALOG_PATH" ]] || die "ADR catalog is unreadable: $CATALOG_PATH" 4 - parse_catalog_rows "$CATALOG_PATH" || die "ADR catalog is malformed: $CATALOG_PATH" 4 - catalog_matches_inventory || die "ADR catalog is stale: $CATALOG_PATH" 4 - fi - if [[ -n "$ADR_DIR" ]]; then - printf 'valid: %s ADR(s) in %s\n' "$count" "$ADR_DIR" - else - echo "valid: 0 ADR(s); no ADR directory detected" - fi -} - -run_list() { - local identifier title status date summary supersedes superseded_by revisit file matched shown relation_suffix grep_status catalog_status - if [[ -n "$STATUS_FILTER" ]] && ! status_is_valid "$STATUS_FILTER"; then - die "unsupported decision status: $STATUS_FILTER" - fi - if [[ -n "$RELATED_FILTER" && ! $RELATED_FILTER =~ ^ADR-[0-9][0-9][0-9][0-9][0-9]*$ ]]; then - die "invalid related decision identifier: $RELATED_FILTER" - fi - [[ ${#RELATED_FILTER} -le 22 ]] || die "related decision identifier exceeds the 18-digit limit" - [[ ${#SEARCH_FILTER} -le 200 ]] || die "--search must be at most 200 bytes" - has_control_char "$SEARCH_FILTER" && die "--search contains unsupported control bytes" - [[ $LIMIT =~ ^[0-9]+$ && $LIMIT -gt 0 && $LIMIT -le 200 ]] || die "--limit must be an integer from 1 to 200" - if [[ -n "$ADR_DIR" ]]; then - catalog_status=0 - catalog_is_fresh "$CATALOG_PATH" || catalog_status=$? - if [[ $catalog_status -eq 0 && -z "$SEARCH_FILTER" ]]; then - catalog_to_inventory - else - [[ $catalog_status -eq 0 ]] || warn_catalog_fallback "$catalog_status" - if ! build_inventory; then cat "$ERRORS" >&2; exit 4; fi - fi - else - if ! build_inventory; then cat "$ERRORS" >&2; exit 4; fi - fi - - matched=0 - shown=0 - while IFS=$'\034' read -r identifier title status date summary supersedes superseded_by revisit file; do - [[ -n "$identifier" ]] || continue - if [[ -n "$STATUS_FILTER" && "$status" != "$STATUS_FILTER" ]]; then continue; fi - if [[ -n "$RELATED_FILTER" ]] && ! relation_contains "$supersedes" "$RELATED_FILTER" && ! relation_contains "$superseded_by" "$RELATED_FILTER"; then - continue - fi - if [[ -n "$SEARCH_FILTER" ]]; then - grep_status=0 - LC_ALL=C grep -i -F -- "$SEARCH_FILTER" "$file" >/dev/null 2>&1 || grep_status=$? - if [[ $grep_status -eq 1 ]]; then continue; fi - [[ $grep_status -eq 0 ]] || die "cannot search ADR file: $file" - fi - matched=$((matched + 1)) - if [[ $shown -lt $LIMIT ]]; then - relation_suffix="" - [[ -z "$supersedes" ]] || relation_suffix="; supersedes: $supersedes" - [[ -z "$superseded_by" ]] || relation_suffix="$relation_suffix; superseded by: $superseded_by" - printf '%s %s architecture — %s — %s%s\n' "$identifier" "$status" "$title" "$file" "$relation_suffix" - shown=$((shown + 1)) - fi - done < "$INVENTORY" - printf 'total: %s (status=%s, search=%s, related-to=%s)\n' "$matched" \ - "${STATUS_FILTER:-all}" "${SEARCH_FILTER:-(none)}" "${RELATED_FILTER:-(none)}" - if [[ $matched -gt $LIMIT ]]; then - printf 'note: showing first %s of %s matching ADRs\n' "$LIMIT" "$matched" - fi -} - -run_catalog_rebuild() { - local count - [[ -n "$ADR_DIR" ]] || die "no ADR directory detected; create or select one with --dir" - if [[ -e "$CATALOG_PATH" || -L "$CATALOG_PATH" ]]; then - [[ ! -L "$CATALOG_PATH" && -f "$CATALOG_PATH" ]] || die "ADR catalog is not a regular file: $CATALOG_PATH" - [[ -r "$CATALOG_PATH" ]] || die "ADR catalog is not readable: $CATALOG_PATH" - LC_ALL=C grep -F -x -e "<!-- $CATALOG_FORMAT -->" -e "<!-- darrow-adr-catalog-v1 -->" -- "$CATALOG_PATH" >/dev/null 2>&1 || \ - die "refusing to replace a README without the derived ADR catalog marker: $CATALOG_PATH" 4 - fi - if ! build_inventory; then cat "$ERRORS" >&2; exit 4; fi - CATALOG_WRITE_TMP=$(mktemp "$ADR_DIR/$CATALOG_NAME.tmp.XXXXXX") || die "cannot create an atomic ADR catalog in: $ADR_DIR" - write_catalog_file "$CATALOG_WRITE_TMP" - mv "$CATALOG_WRITE_TMP" "$CATALOG_PATH" || die "cannot replace ADR catalog: $CATALOG_PATH" - CATALOG_WRITE_TMP="" - count=$(inventory_count) || die "cannot count ADR inventory" - printf 'catalog: %s\nrecords: %s\n' "$CATALOG_PATH" "$count" -} - -run_catalog_check() { - local count - [[ -n "$ADR_DIR" ]] || die "no ADR directory detected; create or select one with --dir" - [[ -e "$CATALOG_PATH" || -L "$CATALOG_PATH" ]] || die "ADR catalog is missing: $CATALOG_PATH" 4 - [[ ! -L "$CATALOG_PATH" && -f "$CATALOG_PATH" ]] || die "ADR catalog is malformed: $CATALOG_PATH" 4 - [[ -r "$CATALOG_PATH" ]] || die "ADR catalog is unreadable: $CATALOG_PATH" 4 - parse_catalog_rows "$CATALOG_PATH" || die "ADR catalog is malformed: $CATALOG_PATH" 4 - if ! build_inventory; then cat "$ERRORS" >&2; exit 4; fi - catalog_matches_inventory || die "ADR catalog is stale: $CATALOG_PATH" 4 - count=$(inventory_count) || die "cannot count ADR inventory" - printf 'fresh: %s\nrecords: %s\n' "$CATALOG_PATH" "$count" -} - -slugify_title() { - local value=$1 slug - [[ ${#value} -le 200 ]] || die "--title must be at most 200 bytes" - if ! slug=$(LC_ALL=C printf '%s' "$value" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9][^a-z0-9]*/-/g; s/^-//; s/-$//'); then - die "cannot derive ADR filename from title" - fi - [[ -n "$slug" ]] || die "--title must contain an ASCII letter or digit" - printf '%s' "$slug" -} - -run_next_id() { - local identifier title status date summary supersedes superseded_by revisit file digits number max next slug target - [[ -n "$ADR_DIR" ]] || die "no ADR directory detected; create or select one with --dir" - if ! build_inventory; then cat "$ERRORS" >&2; exit 4; fi - max=0 - while IFS=$'\034' read -r identifier title status date summary supersedes superseded_by revisit file; do - [[ -n "$identifier" ]] || continue - digits=${identifier#ADR-} - number=$((10#$digits)) - if [[ $number -gt $max ]]; then max=$number; fi - done < "$INVENTORY" - [[ $max -lt 999999999999999999 ]] || die "ADR identifier space is exhausted" 4 - next=$((max + 1)) - printf -v identifier 'ADR-%04d' "$next" - printf 'id: %s\n' "$identifier" - if [[ -n "$TITLE_ARG" ]]; then - slug=$(slugify_title "$TITLE_ARG") - target="$ADR_DIR/$identifier-$slug.md" - [[ ! -e "$target" && ! -L "$target" ]] || die "next ADR path is already occupied: $target" 4 - printf 'path: %s\n' "$target" - fi -} - -COMMAND=${1:-} -[[ -n "$COMMAND" ]] || usage -shift - -CATALOG_ACTION="" -if [[ "$COMMAND" == "catalog" ]]; then - CATALOG_ACTION=${1:-} - [[ "$CATALOG_ACTION" == "rebuild" || "$CATALOG_ACTION" == "check" ]] || usage - shift -fi - -if [[ "$COMMAND" == "check-transition" ]]; then - FROM_STATUS="" - TO_STATUS="" - while [[ $# -gt 0 ]]; do - case "$1" in - --from) [[ $# -ge 2 ]] || usage; FROM_STATUS=$2; shift 2 ;; - --to) [[ $# -ge 2 ]] || usage; TO_STATUS=$2; shift 2 ;; - *) usage ;; - esac - done - [[ -n "$FROM_STATUS" && -n "$TO_STATUS" ]] || usage - has_control_char "$FROM_STATUS" && die "--from contains unsupported control bytes" - has_control_char "$TO_STATUS" && die "--to contains unsupported control bytes" - check_transition "$FROM_STATUS" "$TO_STATUS" - exit 0 -fi - -case "$COMMAND" in inspect|list|next-id|validate|catalog|canonical-path) ;; *) usage ;; esac - -REPO_ARG=. -DIR_ARG="" -STATUS_FILTER="" -SEARCH_FILTER="" -RELATED_FILTER="" -LIMIT=50 -TITLE_ARG="" -PATH_ARG="" -DIR_SET=0 -STATUS_SET=0 -SEARCH_SET=0 -RELATED_SET=0 -LIMIT_SET=0 -TITLE_SET=0 -PATH_SET=0 -while [[ $# -gt 0 ]]; do - case "$1" in - --repo) [[ $# -ge 2 ]] || usage; REPO_ARG=$2; shift 2 ;; - --dir) [[ $# -ge 2 ]] || usage; DIR_ARG=$2; DIR_SET=1; shift 2 ;; - --status) [[ $# -ge 2 ]] || usage; STATUS_FILTER=$2; STATUS_SET=1; shift 2 ;; - --search) [[ $# -ge 2 ]] || usage; SEARCH_FILTER=$2; SEARCH_SET=1; shift 2 ;; - --related-to) [[ $# -ge 2 ]] || usage; RELATED_FILTER=$2; RELATED_SET=1; shift 2 ;; - --limit) [[ $# -ge 2 ]] || usage; LIMIT=$2; LIMIT_SET=1; shift 2 ;; - --title) [[ $# -ge 2 ]] || usage; TITLE_ARG=$2; TITLE_SET=1; shift 2 ;; - --path) [[ $# -ge 2 ]] || usage; PATH_ARG=$2; PATH_SET=1; shift 2 ;; - *) usage ;; - esac -done - -case "$COMMAND" in - inspect|validate|catalog) - [[ $STATUS_SET -eq 0 && $SEARCH_SET -eq 0 && $RELATED_SET -eq 0 && $LIMIT_SET -eq 0 && $TITLE_SET -eq 0 && $PATH_SET -eq 0 ]] || usage - ;; - list) - [[ $TITLE_SET -eq 0 && $PATH_SET -eq 0 ]] || usage - ;; - next-id) - [[ $STATUS_SET -eq 0 && $SEARCH_SET -eq 0 && $RELATED_SET -eq 0 && $LIMIT_SET -eq 0 && $PATH_SET -eq 0 ]] || usage - ;; - canonical-path) - [[ $DIR_SET -eq 0 && $STATUS_SET -eq 0 && $SEARCH_SET -eq 0 && $RELATED_SET -eq 0 && $LIMIT_SET -eq 0 && $TITLE_SET -eq 0 && $PATH_SET -eq 1 ]] || usage - ;; -esac - -has_control_char "$REPO_ARG" && die "--repo contains unsupported control bytes" -has_control_char "$DIR_ARG" && die "--dir contains unsupported control bytes" -has_control_char "$STATUS_FILTER" && die "--status contains unsupported control bytes" -has_control_char "$SEARCH_FILTER" && die "--search contains unsupported control bytes" -has_control_char "$RELATED_FILTER" && die "--related-to contains unsupported control bytes" -has_control_char "$TITLE_ARG" && die "--title contains unsupported control bytes" -has_control_char "$PATH_ARG" && die "--path contains unsupported control bytes" -[[ ${#PATH_ARG} -le 1000 ]] || die "--path must be at most 1000 bytes" - -ROOT=$(resolve_root "$REPO_ARG") -if [[ "$COMMAND" == "canonical-path" ]]; then - run_canonical_path - exit 0 -fi -select_adr_directory -DECISION_TMP=$(mktemp -d "${TMPDIR:-/tmp}/darrow-decisions.XXXXXX") -CATALOG_WRITE_TMP="" -trap '[[ -z "$CATALOG_WRITE_TMP" ]] || rm -f "$CATALOG_WRITE_TMP"; rm -rf "$DECISION_TMP"' EXIT -SEP=$'\034' -INVENTORY="$DECISION_TMP/inventory" -ERRORS="$DECISION_TMP/errors" -CATALOG_PATH="" -[[ -z "$ADR_DIR" ]] || CATALOG_PATH="$ADR_DIR/$CATALOG_NAME" - -case "$COMMAND" in - inspect) run_inspect ;; - list) run_list ;; - next-id) run_next_id ;; - validate) run_validate ;; - catalog) - case "$CATALOG_ACTION" in - rebuild) run_catalog_rebuild ;; - check) run_catalog_check ;; - esac - ;; -esac diff --git a/plugins/foundation/darrow-decisions/skills/capture-decision/SKILL.md b/plugins/foundation/darrow-decisions/skills/capture-decision/SKILL.md index 3d2a0818..c244c850 100644 --- a/plugins/foundation/darrow-decisions/skills/capture-decision/SKILL.md +++ b/plugins/foundation/darrow-decisions/skills/capture-decision/SKILL.md @@ -1,14 +1,15 @@ --- name: capture-decision -description: Capture one explicit decision or maintain its lifecycle at the canonical scope. Use for recording a settled choice, recording an architecture proposal, correcting decision metadata, or superseding an accepted decision. +description: Capture a requested decision or maintain its lifecycle at the canonical scope. Use for requests to record or accept a choice, preserve an architecture proposal, correct decision metadata, or supersede an accepted decision, including when the choice is unresolved or its authority is missing. --- # Capture a decision Persist exactly one explicit choice where its consumers must follow it. -Use the `decision` facade at `<skill-dir>/../../bin/decision`, where -`<skill-dir>` contains this file. Run it with Bash. The facade owns ADR +Use the frozen `darrow-decision` entrypoint shown below, where +`<skill-dir>` contains this file. The contained package lives at +`<skill-dir>/../../backend`. The facade owns ADR discovery, numbering, structural validation, lifecycle transitions, and relationship integrity. Treat a facade refusal as authoritative: correct the candidate or report the refusal. @@ -36,7 +37,7 @@ candidate or report the refusal. Run: ```sh -bash <skill-dir>/../../bin/decision inspect +uv run --quiet --frozen --no-dev --project "<skill-dir>/../../backend" darrow-decision inspect ``` Treat reported policy and specification surfaces as discovery candidates. @@ -70,7 +71,7 @@ or the exact ownership gap is known before any write. Search before writing: ```sh -bash <skill-dir>/../../bin/decision list --search <distinctive-subject> +uv run --quiet --frozen --no-dev --project "<skill-dir>/../../backend" darrow-decision list --search <distinctive-subject> ``` Also search the applicable specification, policy, scoped guidance, and—when @@ -107,7 +108,7 @@ field that could change the recorded effect has explicit evidence. - **New ADR:** obtain its identifier and absolute path verbatim: ```sh - bash <skill-dir>/../../bin/decision next-id --dir <adr-dir> --title <title> + uv run --quiet --frozen --no-dev --project "<skill-dir>/../../backend" darrow-decision next-id --dir <adr-dir> --title <title> ``` Keep the returned `ADR-` filename. Write `Status`, `Date`, one non-empty @@ -118,7 +119,7 @@ field that could change the recorded effect has explicit evidence. - **Lifecycle change:** validate it first: ```sh - bash <skill-dir>/../../bin/decision check-transition --from <old> --to <new> + uv run --quiet --frozen --no-dev --project "<skill-dir>/../../backend" darrow-decision check-transition --from <old> --to <new> ``` `Rejected` never took effect. `Deprecated` once applied but has no named @@ -132,7 +133,7 @@ After any ADR creation or edit, refresh its directory's derived catalog with the same operation used for a manual rebuild: ```sh -bash <skill-dir>/../../bin/decision catalog rebuild --dir <adr-dir> +uv run --quiet --frozen --no-dev --project "<skill-dir>/../../backend" darrow-decision catalog rebuild --dir <adr-dir> ``` This creates or replaces only the derived `README.md` beside the ADRs. Do not @@ -152,8 +153,8 @@ unresolved/refusal result is ready. For ADR changes, require both: ```sh -bash <skill-dir>/../../bin/decision catalog check --dir <adr-dir> -bash <skill-dir>/../../bin/decision validate --dir <adr-dir> +uv run --quiet --frozen --no-dev --project "<skill-dir>/../../backend" darrow-decision catalog check --dir <adr-dir> +uv run --quiet --frozen --no-dev --project "<skill-dir>/../../backend" darrow-decision validate --dir <adr-dir> ``` Success requires the literal `valid` result. For a specification or policy, @@ -167,7 +168,7 @@ verification as the persistence proof for the already-captured record. For a repository sink, make this the final tool action before responding: ```sh -bash <skill-dir>/../../bin/decision canonical-path --path <record> +uv run --quiet --frozen --no-dev --project "<skill-dir>/../../backend" darrow-decision canonical-path --path <record> ``` Copy the emitted `path:` value verbatim; keep it absolute. diff --git a/plugins/foundation/darrow-decisions/skills/capture-decision/evals/accepted-architecture.yaml b/plugins/foundation/darrow-decisions/skills/capture-decision/evals/accepted-architecture.yaml index c3ef909e..192eded9 100644 --- a/plugins/foundation/darrow-decisions/skills/capture-decision/evals/accepted-architecture.yaml +++ b/plugins/foundation/darrow-decisions/skills/capture-decision/evals/accepted-architecture.yaml @@ -30,8 +30,9 @@ fixture: Runtime tooling follows Node.js conventions. setup: >- - cp "{{case_dir}}/../../../bin/decision" .git/decision-facade && - chmod +x .git/decision-facade + mkdir -p .git/decision-backend && + cp "{{case_dir}}/../../../backend/pyproject.toml" "{{case_dir}}/../../../backend/uv.lock" .git/decision-backend/ && + cp -R "{{case_dir}}/../../../backend/src" .git/decision-backend/ checks: - name: changes one next-id ADR and its derived catalog run: >- @@ -65,13 +66,13 @@ checks: expect_regex: "PostgreSQL" - name: deterministic ADR validation passes run: >- - bash .git/decision-facade validate --repo . + uv run --quiet --frozen --no-dev --project .git/decision-backend darrow-decision validate --repo . expect_regex: "^valid: 2 ADR\\(s\\) in /" - name: capture refresh matches a manual deterministic rebuild run: >- - bash .git/decision-facade catalog check --repo . >/dev/null; + uv run --quiet --frozen --no-dev --project .git/decision-backend darrow-decision catalog check --repo . >/dev/null; before=$(git hash-object docs/decisions/README.md); - bash .git/decision-facade catalog rebuild --repo . >/dev/null; + uv run --quiet --frozen --no-dev --project .git/decision-backend darrow-decision catalog rebuild --repo . >/dev/null; after=$(git hash-object docs/decisions/README.md); test "$before" = "$after" && printf 'identical\n' expect_exact: "identical" diff --git a/plugins/foundation/darrow-decisions/skills/capture-decision/evals/compare-options.yaml b/plugins/foundation/darrow-decisions/skills/capture-decision/evals/compare-options.yaml new file mode 100644 index 00000000..af96ff3a --- /dev/null +++ b/plugins/foundation/darrow-decisions/skills/capture-decision/evals/compare-options.yaml @@ -0,0 +1,23 @@ +id: capture-decision-compare-options +invariant: "DM-2,DM-C0" +activation: negative +activation_excludes: [capture-decision, list-decisions] +mount_plugin_skills: true +prompt: >- + Compare the operational trade-offs of SQLite and PostgreSQL for a small + service. +fixture: + commits: + - message: "chore: init" + files: + README.md: "# Service\n" +checks: + - name: comparison discusses both alternatives + run: cat .git/last-message.md + expect_regex: "SQLite[\\s\\S]*PostgreSQL|PostgreSQL[\\s\\S]*SQLite" + - name: comparison creates no canonical decision + run: git status --porcelain --untracked-files=all + not_regex: "\\S" + - name: comparison creates no commit + run: git rev-list --all --reflog --count + expect_exact: "1" diff --git a/plugins/foundation/darrow-decisions/skills/capture-decision/evals/metadata-correction.yaml b/plugins/foundation/darrow-decisions/skills/capture-decision/evals/metadata-correction.yaml index 0b4e3ae2..0e76bb49 100644 --- a/plugins/foundation/darrow-decisions/skills/capture-decision/evals/metadata-correction.yaml +++ b/plugins/foundation/darrow-decisions/skills/capture-decision/evals/metadata-correction.yaml @@ -30,8 +30,9 @@ fixture: The application owns one repository-local database. setup: >- - cp "{{case_dir}}/../../../bin/decision" .git/decision-facade && - chmod +x .git/decision-facade + mkdir -p .git/decision-backend && + cp "{{case_dir}}/../../../backend/pyproject.toml" "{{case_dir}}/../../../backend/uv.lock" .git/decision-backend/ && + cp -R "{{case_dir}}/../../../backend/src" .git/decision-backend/ checks: - name: changes the existing ADR and refreshes its catalog run: >- @@ -50,11 +51,11 @@ checks: expect_regex: "^preserved$" - name: deterministic validation passes run: >- - bash .git/decision-facade validate --repo . + uv run --quiet --frozen --no-dev --project .git/decision-backend darrow-decision validate --repo . expect_regex: "^valid: 1 ADR\\(s\\) in /" - name: refreshed catalog is deterministic and fresh run: >- - bash .git/decision-facade catalog check --repo . + uv run --quiet --frozen --no-dev --project .git/decision-backend darrow-decision catalog check --repo . expect_regex: "^fresh: /" - name: response identifies correction and absolute canonical path run: >- diff --git a/plugins/foundation/darrow-decisions/skills/capture-decision/evals/proposed-provenance.yaml b/plugins/foundation/darrow-decisions/skills/capture-decision/evals/proposed-provenance.yaml index 68580959..91248d3f 100644 --- a/plugins/foundation/darrow-decisions/skills/capture-decision/evals/proposed-provenance.yaml +++ b/plugins/foundation/darrow-decisions/skills/capture-decision/evals/proposed-provenance.yaml @@ -14,8 +14,9 @@ fixture: README.md: "# Service\n" docs/decisions/.gitkeep: "" setup: >- - cp "{{case_dir}}/../../../bin/decision" .git/decision-facade && - chmod +x .git/decision-facade + mkdir -p .git/decision-backend && + cp "{{case_dir}}/../../../backend/pyproject.toml" "{{case_dir}}/../../../backend/uv.lock" .git/decision-backend/ && + cp -R "{{case_dir}}/../../../backend/src" .git/decision-backend/ checks: - name: creates one proposed ADR and its derived catalog run: >- @@ -48,7 +49,7 @@ checks: flags: i - name: deterministic validation passes run: >- - bash .git/decision-facade validate --repo . + uv run --quiet --frozen --no-dev --project .git/decision-backend darrow-decision validate --repo . expect_regex: "^valid: 1 ADR\\(s\\) in /" - name: response reports the absolute canonical sink run: >- diff --git a/plugins/foundation/darrow-decisions/skills/capture-decision/evals/supersede-accepted.yaml b/plugins/foundation/darrow-decisions/skills/capture-decision/evals/supersede-accepted.yaml index de098573..e2f9911b 100644 --- a/plugins/foundation/darrow-decisions/skills/capture-decision/evals/supersede-accepted.yaml +++ b/plugins/foundation/darrow-decisions/skills/capture-decision/evals/supersede-accepted.yaml @@ -50,8 +50,9 @@ fixture: Clients integrate through resource endpoints. setup: >- - cp "{{case_dir}}/../../../bin/decision" .git/decision-facade && - chmod +x .git/decision-facade + mkdir -p .git/decision-backend && + cp "{{case_dir}}/../../../backend/pyproject.toml" "{{case_dir}}/../../../backend/uv.lock" .git/decision-backend/ && + cp -R "{{case_dir}}/../../../backend/src" .git/decision-backend/ checks: - name: changes the old ADR, adds one new ADR, and refreshes the catalog run: >- @@ -91,7 +92,7 @@ checks: expect_exact: "valid" - name: historical reciprocal supersession chain validates run: >- - bash .git/decision-facade validate --repo . + uv run --quiet --frozen --no-dev --project .git/decision-backend darrow-decision validate --repo . expect_regex: "^valid: 3 ADR\\(s\\) in /" - name: capture creates no commit run: git rev-list --all --reflog --count diff --git a/plugins/foundation/darrow-decisions/skills/capture-decision/evals/unresolved-authority.yaml b/plugins/foundation/darrow-decisions/skills/capture-decision/evals/unresolved-authority.yaml index d0340e62..c67d0169 100644 --- a/plugins/foundation/darrow-decisions/skills/capture-decision/evals/unresolved-authority.yaml +++ b/plugins/foundation/darrow-decisions/skills/capture-decision/evals/unresolved-authority.yaml @@ -1,5 +1,5 @@ id: capture-decision-unresolved-authority -invariant: DM-1 +invariant: "DM-1,DM-C0" activation: competition mount_plugin_skills: true prompt: >- diff --git a/plugins/foundation/darrow-decisions/skills/list-decisions/SKILL.md b/plugins/foundation/darrow-decisions/skills/list-decisions/SKILL.md index aff17cc6..290a7531 100644 --- a/plugins/foundation/darrow-decisions/skills/list-decisions/SKILL.md +++ b/plugins/foundation/darrow-decisions/skills/list-decisions/SKILL.md @@ -8,8 +8,9 @@ description: Find recorded decisions read-only across ADRs, specifications, poli Answer the requested decision question from canonical records without changing repository or external state. -Use the `decision` facade at `<skill-dir>/../../bin/decision`, where -`<skill-dir>` contains this file. Run it with Bash. The facade owns ADR +Use the frozen `darrow-decision` entrypoint shown below, where +`<skill-dir>` contains this file. The contained package lives at +`<skill-dir>/../../backend`. The facade owns ADR discovery, catalog freshness, validation, filtering, relationships, and output caps. Treat an unreadable or malformed required record as a refusal rather than a skippable result. The catalog is derived metadata, never a canonical decision @@ -43,8 +44,8 @@ unfiltered, and the intended owner set is known. Run: ```sh -bash <skill-dir>/../../bin/decision inspect -bash <skill-dir>/../../bin/decision list [--dir <adr-dir>] \ +uv run --quiet --frozen --no-dev --project "<skill-dir>/../../backend" darrow-decision inspect +uv run --quiet --frozen --no-dev --project "<skill-dir>/../../backend" darrow-decision list [--dir <adr-dir>] \ [--status Proposed|Accepted|Rejected|Deprecated|Superseded] \ [--search <subject>] [--related-to <ADR-NNNN>] [--limit <n>] ``` @@ -93,7 +94,7 @@ scope, and relationship set, with no excluded record in the draft. For every repository result, run: ```sh -bash <skill-dir>/../../bin/decision canonical-path --path <record> +uv run --quiet --frozen --no-dev --project "<skill-dir>/../../backend" darrow-decision canonical-path --path <record> ``` Copy each emitted `path:` value verbatim. Every displayed repository path must @@ -109,7 +110,9 @@ For an empty result, state the filters and canonical surfaces inspected. Finish with a **silent-filter audit**: remove every excluded identifier, title, path, effect, and exclusion explanation. Keep only criteria and surface names in -the coverage summary. +the coverage summary. Start the response with the first matching result (or the +empty-result statement); keep internal comparisons with excluded records out of +introductory prose as well as the result rows. **Complete when:** every result occupies one line, every repository path is facade-verified and absolute, the requested coverage is explicit, and excluded diff --git a/plugins/foundation/darrow-decisions/skills/list-decisions/evals/catalog-fallback.yaml b/plugins/foundation/darrow-decisions/skills/list-decisions/evals/catalog-fallback.yaml index 1a9139bf..5076b364 100644 --- a/plugins/foundation/darrow-decisions/skills/list-decisions/evals/catalog-fallback.yaml +++ b/plugins/foundation/darrow-decisions/skills/list-decisions/evals/catalog-fallback.yaml @@ -8,14 +8,16 @@ prompt: >- do not change anything. fixture: bin: - bash: | + uv: | #!/bin/sh - case "$1" in - */bin/decision) + git_dir=$(git rev-parse --git-dir) || exit 1 + real_uv=$(awk '!/fixture-bin/ && !seen {print; seen=1}' "$git_dir/fixture-uv-paths") + case " $* " in + *" darrow-decision "*) git_dir=$(git rev-parse --git-dir) || exit 1 stderr_file="$git_dir/decision-stderr.$$" status=0 - /bin/bash "$@" 2>"$stderr_file" || status=$? + "$real_uv" "$@" 2>"$stderr_file" || status=$? cat "$stderr_file" >&2 { printf 'command' @@ -27,7 +29,8 @@ fixture: exit "$status" ;; esac - exec /bin/bash "$@" + exec "$real_uv" "$@" + setup: which -a uv >.git/fixture-uv-paths commits: - message: "chore: init" files: @@ -74,7 +77,7 @@ checks: - name: decision facade performs the warned malformed-catalog fallback run: >- root=$(pwd -P); log=.git/decision-facade.log; - grep -E '^command .*[/]bin/decision list( |$)' "$log" >/dev/null; + grep -E '^command .* darrow-decision list( |$)' "$log" >/dev/null; grep -F "warning: ADR catalog is malformed; using full scan: $root/docs/decisions/README.md" "$log" >/dev/null; printf 'warned fallback\n' expect_exact: "warned fallback" diff --git a/python-packages.txt b/python-packages.txt index dffaa49e..b1e65a55 100644 --- a/python-packages.txt +++ b/python-packages.txt @@ -5,3 +5,4 @@ plugins/capability/darrow-observability-langfuse/backend plugins/capability/darrow-verification/skills/verify-change/backend plugins/foundation/darrow-skill-authoring/skills/author-agent-skill/backend plugins/capability/darrow-tickets-github/backend +plugins/foundation/darrow-decisions/backend