diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 5227f2fe..4702ff34 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -254,9 +254,31 @@ jobs: # margin lets through is up to three tests that stop being # COLLECTED at all, and a lost file is dozens of tests, never # three. - MIN_SELECTED: "2476" - MIN_PASSED: "2465" + # + # RE-PINNED BY plan 034 T082 (Copilot at openDox-code#76 947411fd, + # r4170556888). Since T037, the cases added by later changes had + # left these floors where they were, so the declared three-test + # margin had grown to several hundred. T082 adds 34 cases and + # re-pins by the rule above: three below the lower of two greens + # of one tree. The tree is `a25606bb`, T082 after merging `main` + # at `0116293a` (T084, T103 and T102 included). Read from run + # 37152269851: attempt 1 (job 111288409713) and its re-run (job + # 111296677742). Both printed `triple: selected=3980 passed=3969 + # skipped=11 failures=0 errors=0`, so no jitter was measured, and + # three below is 3977 / 3966. A local run of the same tree read + # 3803 passed and 177 skipped (3980 selected), with no PostgreSQL + # service, so the runtime cases skip there. That is a check, not + # the source. + MIN_SELECTED: "3977" + MIN_PASSED: "3966" # EXACT — the load-bearing number. Moves only with its reason. + # + # HELD AT 11 by plan 034 T082. While T082 was stacked ahead of T084 + # this pin read 16: five of `tests/test_chat_model_configuration.py`'s + # 16.5 cases ran as strict xfails naming T084, and JUnit writes an + # xfail as a skip. T084 landed (openDox-code#77, `e49b17c3`), T082 + # merged `main` and removed the five markers in that merge, and the + # five now pass. So T082 adds no skip. EXPECT_SKIPPED: "11" run: | python3 - <<'PY' > triple.env diff --git a/src/opendox/cli.py b/src/opendox/cli.py index 6f1a0f97..7165c451 100644 --- a/src/opendox/cli.py +++ b/src/opendox/cli.py @@ -88,8 +88,9 @@ # below, beside `build_parser()`. # Neither module names `openxdox` or `ideation_dashboard`, so this import adds # no reach: `corpus_adapter` is stdlib-only (F4.1's own scan proves it), and -# `local_git_adapter` names only `opendox.runtime.config` and -# `opendox.corpus_adapter` besides the stdlib. +# `local_git_adapter` names only `opendox.runtime.config`, +# `opendox.corpus_adapter` and `opendox.doxbench_intake` (itself stdlib-only, +# with the `doxbench_binding` it reads; plan 034 T082) besides the stdlib. from opendox import corpus_adapter # noqa: E402 from opendox.runtime import local_git_adapter # noqa: E402 # THE INSTALL SHAPE (plan 034 T070; #1144 13.4-13.6): `generate-and-open` @@ -1305,9 +1306,11 @@ def _default_home_factory(root): already shows worktree bytes, so the standalone default matches it rather than reading the session's git HEAD. `WorkingTreeCorpus` (`local_git_adapter.py`) is `LocalGitCorpus` with `list_documents`/`read` - aimed at the filesystem instead of a resolved commit; see its own - docstring for what stays unchanged (`resolve`, `classify`, `check`, - `write_back`) and what does not. + aimed at the filesystem instead of a resolved commit, and with openDox's + own settings documents (`doxbench_intake.SETTINGS_DOCUMENTS`, plan 034 + T082) left out of its listing and out of a whole-corpus `check`; see its + own docstring for what stays unchanged (`resolve`, `classify`, + `write_back`, and a `check` of named subjects) and what does not. A FRESH ADAPTER EVERY CALL, ON PURPOSE: nothing here is held onto across calls, so there is no listing cache keyed on whatever HEAD was at an diff --git a/src/opendox/default_columns.py b/src/opendox/default_columns.py index b00b195e..0a5f9a0f 100644 --- a/src/opendox/default_columns.py +++ b/src/opendox/default_columns.py @@ -67,9 +67,10 @@ from opendox import defaults from opendox.column_seams import GATE_RECORDS_REFUSAL # openDox's OWN SETTINGS DOCUMENTS, never a tile's editable material (plan 034 -# T084, adversarial review 2, M1). Both modules are stdlib-only at import. -from opendox.doxbench_binding import DEFAULT_BINDINGS_RELPATH -from opendox.doxbench_intake import DEFAULT_DECLARATIONS_RELPATH +# T084, adversarial review 2, M1). Imported, not copied: the list is declared +# once, beside the two paths it names (plan 034 T082). `doxbench_intake` is +# stdlib-only at import. +from opendox.doxbench_intake import SETTINGS_DOCUMENTS as _SETTINGS_DOCUMENTS from opendox.boundary import GATE_SIDE_EFFECT, BoundaryViolation, HumanGate, Refusal from opendox.doxbench_scope_types import ( ScopeConfinementError, @@ -367,8 +368,7 @@ def _section(key: str, label: str, note: str, references: Iterable[Any], *, #: refuses them whatever section carries them. An in-root symlink that reaches #: one is treated as the document it reaches (`_settings_test`). The corpus #: scan's own exclusion (openDox-code#76) is a second layer, not this one. -SETTINGS_DOCUMENTS: frozenset[str] = frozenset({ - DEFAULT_BINDINGS_RELPATH, DEFAULT_DECLARATIONS_RELPATH}) +SETTINGS_DOCUMENTS: frozenset[str] = frozenset(_SETTINGS_DOCUMENTS) _SETTINGS_SECTION = ("settings", "openDox's own settings documents", "the install's settings: readable here, and never " diff --git a/src/opendox/doxbench_intake.py b/src/opendox/doxbench_intake.py index 1f6a36b0..1619976a 100644 --- a/src/opendox/doxbench_intake.py +++ b/src/opendox/doxbench_intake.py @@ -154,6 +154,20 @@ #: install has approved and which are still waiting on a human. DEFAULT_DECLARATIONS_RELPATH = "ideation/dashboard/model-declarations.yaml" +#: openDox's OWN SETTINGS DOCUMENTS, at their default paths: the model bindings +#: (`doxbench_binding.DEFAULT_BINDINGS_RELPATH`) and the declarations above. +#: Each lives in the served checkout on purpose, where its operator can read +#: and commit it. But each is a model's configuration and not one of the user's +#: documents, so openDox's standalone corpus default lists neither: this is +#: the default of `runtime.local_git_adapter.WorkingTreeCorpus`'s `excluded`. +#: Configuring a model then changes no document, no generated snapshot and no +#: view (#1144 16.5, "every other surface ... answers exactly as it does with a +#: model configured"; plan 034 T082). Without this rule, `opendox model-binding +#: add` added its own bindings document to the corpus as a `source` document. A +#: host that brings its own corpus adapter decides for itself. +SETTINGS_DOCUMENTS: tuple[str, ...] = ( + binding_mod.DEFAULT_BINDINGS_RELPATH, DEFAULT_DECLARATIONS_RELPATH) + #: How long an approval is good for. A POLICY CONSTANT, not an operator input: #: `credential-contracts` holds that a grant without `expires_at` is invalid, and #: an expiry a requester chooses is an expiry that is always far away. Ninety diff --git a/src/opendox/runtime/local_git_adapter.py b/src/opendox/runtime/local_git_adapter.py index 54472eff..04f1ae19 100644 --- a/src/opendox/runtime/local_git_adapter.py +++ b/src/opendox/runtime/local_git_adapter.py @@ -99,6 +99,12 @@ from typing import IO from opendox.runtime import config +# openDox's own settings documents, which `WorkingTreeCorpus` (the standalone +# default) leaves out of its listing (plan 034 T082). Imported, not copied: the +# list is declared once, beside the two paths it names. `doxbench_intake` and +# the `doxbench_binding` it reads import the standard library only, so this +# module still costs the standard library alone to import. +from opendox.doxbench_intake import SETTINGS_DOCUMENTS from opendox.corpus_adapter import ( CORPUS_ABSENT, CORPUS_READ_ONLY, @@ -3013,8 +3019,10 @@ class WorkingTreeCorpus(LocalGitCorpus): matches it instead of reading a commit a local session has since edited past. - `resolve()`, `check()` and `write_back()` are UNCHANGED, inherited from - `LocalGitCorpus` exactly. So is `classify()`: its header read + `resolve()` and `write_back()` are UNCHANGED, inherited from + `LocalGitCorpus` exactly. So is `check()`, except that a WHOLE-corpus + check leaves out the paths `excluded` names (see below). So is + `classify()`: its header read (`_header_of`) goes THROUGH `self.read()`, which is why overriding `read` alone is enough to make classification see the same bytes this adapter lists and serves. Only WHERE `list_documents`/`read` get their bytes @@ -3081,15 +3089,65 @@ class WorkingTreeCorpus(LocalGitCorpus): `WorkingTreeCorpus()`, hands every standalone caller an adapter whose `classify` obliges them, and `authoring.required_header_fields()` answers them. Nothing else about the class changes: a document without them is - still listed and read, and `classify` reports what is missing.""" + still listed and read, and `classify` reports what is missing. + + OPENDOX'S OWN SETTINGS DOCUMENTS ARE NOT LISTED (plan 034 T082; #1144 + 16.5, RULED by the holder 2026-10-02, option (a)). `excluded` names the + paths the listing leaves out, by EXACT corpus-relative key, whether the + file is tracked or untracked and whatever revision the listing reads. Its + default is `doxbench_intake.SETTINGS_DOCUMENTS`: the model bindings + document and the intake declarations document, at their default paths. + `opendox model-binding add` writes the first into the checkout, where its + operator can read and commit it. Before this rule it joined the corpus as + a `source` document, so the snapshot, the generated output and every view + changed the moment a model was configured. This class's only constructor + is openDox's standalone default (`cli.py`'s and `serve.py`'s + `_default_home_factory`), so the default is that default's rule. A host + that brings its own corpus adapter decides for itself, and `excluded=()` + lists everything. A path left out is still a file, and the source route + still serves it by name: the rule is about what the corpus LISTS, not + what exists.""" def __init__(self, *, executable: str = "git", write_path: str | None = WRITE_PATH, kind_field: str | None = None, - required_fields: tuple[str, ...] = NEUTRAL_FIELDS) -> None: + required_fields: tuple[str, ...] = NEUTRAL_FIELDS, + excluded: tuple[str, ...] = SETTINGS_DOCUMENTS) -> None: super().__init__(executable=executable, write_path=write_path, kind_field=kind_field, required_fields=required_fields) + self._excluded = frozenset(excluded) + + def list_documents(self, corpus: ResolvedCorpus, + scope: str = SCOPE_ALL) -> tuple[DocumentId, ...]: + """The parent's listing, minus `excluded`. Applied here, after both + of `_list_documents_bound`'s branches, so a pinned revision leaves + the same paths out as the working tree does.""" + listed = super().list_documents(corpus, scope) + if not self._excluded: + return listed + return tuple(d for d in listed if d.key not in self._excluded) + + def check(self, corpus: ResolvedCorpus, + subjects: tuple[DocumentId, ...] | None = None) -> tuple[Finding, ...]: + """The parent's verdict, over the documents this corpus LISTS. + + `subjects=None` means the whole corpus (`CorpusAdapter.check`), and + an excluded path is not in the corpus this class lists. So a finding + on an excluded path is left out of a whole-corpus check, and an + edited, committed bindings document is not reported as an uncommitted + document the corpus does not hold (Copilot at openDox-code#76, + r4170556938). THAT IS ALL THIS FILTER DOES: every other finding the + parent reports stands as it did before T082, including one on a + tracked path deleted from the working tree, which the listing omits + (`_list_documents_bound`) and the parent's diff still reports as a + divergence from the resolved commit. A subject the caller NAMES is + answered whether or not it is excluded: asking about one file by name + is not asking about the corpus.""" + findings = super().check(corpus, subjects) + if subjects is not None or not self._excluded: + return findings + return tuple(f for f in findings if f.subject not in self._excluded) def _list_documents_bound(self, git: GitRunner, corpus: ResolvedCorpus, scope: str) -> tuple[DocumentId, ...]: diff --git a/src/opendox/serve.py b/src/opendox/serve.py index 2fe5ec6a..41ef1c71 100644 --- a/src/opendox/serve.py +++ b/src/opendox/serve.py @@ -1913,9 +1913,11 @@ def _default_home_factory(root): already shows worktree bytes, so the standalone default matches it rather than reading the session's git HEAD. `WorkingTreeCorpus` (`local_git_adapter.py`) is `LocalGitCorpus` with `list_documents`/`read` - aimed at the filesystem instead of a resolved commit; see its own - docstring for what stays unchanged (`resolve`, `classify`, `check`, - `write_back`) and what does not. + aimed at the filesystem instead of a resolved commit, and with openDox's + own settings documents (`doxbench_intake.SETTINGS_DOCUMENTS`, plan 034 + T082) left out of its listing and out of a whole-corpus `check`; see its + own docstring for what stays unchanged (`resolve`, `classify`, + `write_back`, and a `check` of named subjects) and what does not. A FRESH ADAPTER EVERY CALL, ON PURPOSE: nothing here is held onto across calls, so there is no listing cache keyed on whatever HEAD was at an diff --git a/tests/test_chat_model_configuration.py b/tests/test_chat_model_configuration.py index f9c88333..96b992b5 100644 --- a/tests/test_chat_model_configuration.py +++ b/tests/test_chat_model_configuration.py @@ -55,6 +55,7 @@ from __future__ import annotations import copy +import dataclasses import http.client import json import os @@ -81,7 +82,7 @@ from opendox import validator as own from opendox.serve_workbench import WorkbenchRoutes from session_fixtures import GATE_TEST_PRINCIPALS -from standalone_child import Child, fresh_repository +from standalone_child import Child, fresh_repository, run_module ROOT = Path(__file__).resolve().parent.parent PLAIN = ROOT / "tests" / "fixtures" / "plain-documents" @@ -853,3 +854,478 @@ def test_the_line_shows_in_that_state_only(rail) -> None: assert rail["pure"] == {"emptyAdopted": doxbench_model.NO_MODEL_CONFIGURED_REMEDY, "emptyThenUnreadable": None, "emptyThenStaleToken": None, "onlyUnavailable": None} + + +# --------------------------------------------------------------------------- +# 6 — 16.5: every other surface works with no model (plan 034's T082) +# --------------------------------------------------------------------------- +# +# #1144's 16.5: "Documents, generation, the views, sessions and saving answer +# exactly as they do with a model configured." So each request below is sent +# TWICE, to two standalone `generate-and-open` children over the SAME commit (the +# second checkout is a byte copy of the first, `.git` included): +# +# * "no model": no binding, and no `omp` on the PATH; +# * "a binding": the copy, after `opendox model-binding add` declared one, which +# is how a standalone user configures a model (the holder's binding states, at +# openDox-code#74). +# +# Each answer must be AN ANSWER: an HTTP response, never a dropped connection, +# with no sibling import refused while it was made. And the two answers must be +# EQUAL, once the values that are per-process by design are set aside: the +# console token, and the paths and pid of each child's own bundled database +# (`/capabilities`' `install.database_bundle`, plan 034 T073). A surface that +# only refuses standalone must refuse alike, and write nothing. +# +# Five of these cases needed T084, which routed the reaches that dropped a +# connection standalone through openDox's seams (plan 034's T084, openDox-code#77; +# #1144 4.3's batch-L addendum, RULED `5920216845` item 1; and `5961364221` item 1 +# for model approval): the session reads, the session controls, model approval +# and the document abstract. They ran as `xfail(strict=True)` naming T084 until +# it landed, and T082 removed the markers in its merge of `main`. + +#: The two postures, in the order every case reports them. +POSTURES = ("no model", "a binding") + +#: The binding the configured posture declares, field by field, through the CLI. +_BINDING_ARGS = ("--id", "a-provider", "--label", "A provider", + "--provider", "a-provider", + "--credential-ref", "opref-0000000000000000", + "--auth-kind", "api_key", "--credential-approver", ACTOR, + "--endpoint", "https://provider.invalid/turn", + "--dialect", binding_mod.DIALECT_XFACTORY_PROMPT_V1, + "--", "a-broker") + +#: A fixture document both checkouts carry. +_DOCUMENT = "notes-rain-barrel-leak.md" + + +#: What `_Answer.comparable` puts in place of a per-process value. +_PER_PROCESS = "" + +#: The one surface whose answer carries per-process values by design. +_CAPABILITIES = "/capabilities" + + +@dataclasses.dataclass(frozen=True) +class _Answer: + """One request's answer, or the fact that the connection dropped.""" + + path: str # the request's path, query included + status: int | None # None: no HTTP response at all + content_type: str | None + body: bytes + reached: tuple[str, ...] # sibling imports the child refused meanwhile + + @property + def dropped(self) -> bool: + return self.status is None + + def comparable(self): + """What two postures must agree on. A JSON body is compared as data; + any other body, byte for byte. + + ONLY `/capabilities`' per-process values are set aside: its console + token, and the values of its `install.database_bundle`, the data and + socket directories and pid of the bundled server each `--local` child + starts under its own state directory (plan 034 T073). The bundle's + SHAPE is still compared: the same keys, and a value on both sides or + on neither. Every other surface is compared whole, so a `console_token` + or any other key that differs between the postures on another surface + fails the comparison (Copilot at openDox-code#76 47e8bb95).""" + body = self.body + if (self.content_type or "").startswith("application/json"): + body = json.loads(self.body or b"null") + if self.path == _CAPABILITIES and isinstance(body, dict): + body = {k: v for k, v in body.items() if k != "console_token"} + install = body.get("install") + if (isinstance(install, dict) + and isinstance(install.get("database_bundle"), dict)): + bundle = {key: (_PER_PROCESS if value is not None else None) + for key, value in install["database_bundle"].items()} + body["install"] = {**install, "database_bundle": bundle} + return self.status, self.content_type, body + + +@dataclasses.dataclass +class _Posture: + name: str + repo: Path + child: Child + base: tuple[str, int] + token: str + + def ask(self, method: str, path: str, body=None) -> _Answer: + before = len(self.child.refused()) + headers = {"X-XF-Console-Token": self.token} + data = None + if body is not None: + headers["Content-Type"] = "application/json" + data = json.dumps(body).encode("utf-8") + connection = http.client.HTTPConnection(*self.base, timeout=30) + try: + connection.request(method, path, body=data, headers=headers) + response = connection.getresponse() + answer = (response.status, response.getheader("Content-Type"), + response.read()) + except (http.client.HTTPException, OSError): + answer = (None, None, b"") + finally: + connection.close() + return _Answer(path, *answer, tuple(self.child.refused()[before:])) + + def checkout(self) -> tuple[str, str]: + """The checkout's HEAD and its full status, untracked files included.""" + def run(*args): + return subprocess.run(["git", "-C", str(self.repo), *args], check=True, + capture_output=True, text=True).stdout + return run("rev-parse", "HEAD"), run("status", "--porcelain", + "--untracked-files=all") + + +@pytest.fixture(scope="module") +def postures(tmp_path_factory): + """The two standalone children. The environment is set only while they + start, because each child copies it then.""" + from opendox import actor_identity as actor_mod + work = tmp_path_factory.mktemp("no-model-surfaces") + started: dict[str, _Posture] = {} + try: + with pytest.MonkeyPatch.context() as patch: + for name in (actor_mod.GATEWAY_ENV, actor_mod.PRINCIPAL_ENV, + actor_mod.ALLOWLIST_ENV): + patch.delenv(name, raising=False) + patch.setenv(actor_mod.ROSTER_ENV, ", ".join(GATE_TEST_PRINCIPALS)) + patch.setenv("PATH", _no_omp_path(work)) + # select-to-edit launches the editor; `true` exits at once + patch.setenv("EDITOR", "true") + no_model = fresh_repository(PLAIN, work / "no-model") + bound = work / "a-binding" / no_model.name + shutil.copytree(no_model, bound, symlinks=True) + added, status = run_module(work / "add", "opendox.cli", "model-binding", + "add", "--repo-root", str(bound), + *_BINDING_ARGS) + assert status == 0, added.stderr_text() + assert added.refused() == [], added.refused() + assert binding_mod.bindings_path(bound).is_file() + for name, repo in zip(POSTURES, (no_model, bound)): + slug = name.replace(" ", "-") + # `--local`: the single-user install (plan 034 T070), as the + # `standalone` fixture above runs it. + child = Child(work / f"child-{slug}", "opendox.cli", + "generate-and-open", "--local", "--repo-root", str(repo), + "--repository", "fixture", "--no-open", "--port", "0", + "--run-dir", str(work / f"run-{slug}"), + "--actor", ACTOR) + started[name] = _Posture(name, repo, child, ("", 0), "") + match = child.wait_for_line(_URL) + base = (match.group(2), int(match.group(3))) + status, capabilities = _request(base, "GET", "/capabilities") + assert status == 200 and capabilities["actions"]["session"] is True + started[name].base = base + started[name].token = capabilities["console_token"] + yield started + for posture in started.values(): + assert posture.child.interrupt() == 0, posture.child.stderr_text() + finally: + for posture in started.values(): + posture.child.kill() + + +def _alike(postures, method: str, path: str, body=None) -> list[_Answer]: + """The same request, to both postures: each an answer, and equal.""" + answers = [postures[name].ask(method, path, body) for name in POSTURES] + for name, answer in zip(POSTURES, answers): + assert not answer.dropped, f"{method} {path} dropped the connection ({name})" + assert answer.reached == (), f"{method} {path} reached {answer.reached} ({name})" + assert answers[0].comparable() == answers[1].comparable(), (method, path) + return answers + + +def _a_refusal(answers, *, code: str | None = None) -> None: + for answer in answers: + assert 400 <= answer.status < 500, answer + body = json.loads(answer.body) + assert body.get("ok") is not True, body + if code is not None: + assert body["error"] == code, body + + +# ---- openDox's own settings documents are not the user's documents ---- +# +# `opendox model-binding add` writes its bindings document INTO the checkout +# (`doxbench_binding.DEFAULT_BINDINGS_RELPATH`), where its operator can read and +# commit it. openDox's standalone corpus reads the working tree, so until T082 +# that document joined the corpus as a `source` document, and every document, +# generation and view answer changed the moment a model was configured +# (measured at openDox-code#74 `9061b22a`). RULED by the holder, 2026-10-02, +# option (a): openDox's standalone corpus default, `WorkingTreeCorpus`, leaves +# out openDox's own settings documents, by exact path. Every assertion below is +# over the FULL listing, written out, with nothing subtracted from it. + +#: A user's own documents under the same directory as the settings documents, +#: one of them with the bindings document's very file name. Each IS listed: the +#: rule names two paths, not a directory and not a file name. +_USER_DOCUMENTS_BESIDE_SETTINGS = ( + "ideation/dashboard/notes.md", + "ideation/dashboard/archive/model-provider-bindings.yaml", +) + + +def _fixture_keys(*extra: str) -> list[str]: + """The full listing a checkout of the fixture holds, plus `extra`.""" + return sorted([p.name for p in PLAIN.iterdir() if p.is_file()] + list(extra)) + + +def _write(root: Path, relpath: str, text: str = "schema_version: 1\n") -> None: + (root / relpath).parent.mkdir(parents=True, exist_ok=True) + (root / relpath).write_text(text, encoding="utf-8") + + +def _commit_all(root: Path) -> str: + from standalone_child import git + git(root, "add", "-A") + git(root, "commit", "-qm", "settings and notes") + return subprocess.run(["git", "-C", str(root), "rev-parse", "HEAD"], check=True, + capture_output=True, text=True).stdout.strip() + + +def _listed(adapter, root: Path, revision: str | None = None) -> list[str]: + from opendox import corpus_adapter + corpus = adapter.resolve(corpus_adapter.CorpusRef( + name="home", location=str(root), revision=revision)) + return [d.key for d in adapter.list_documents(corpus)] + + +def test_openDoxs_settings_documents_are_declared_once() -> None: + """The two default paths, by their own constants: the very tuple the + standalone corpus's default reads, and the set the scope default keeps + out of every owned section (T084's `default_columns.SETTINGS_DOCUMENTS`, + built from it).""" + import inspect + from opendox import default_columns + from opendox.runtime import local_git_adapter as lga + assert intake_mod.SETTINGS_DOCUMENTS == (binding_mod.DEFAULT_BINDINGS_RELPATH, + intake_mod.DEFAULT_DECLARATIONS_RELPATH) + default = inspect.signature(lga.WorkingTreeCorpus).parameters["excluded"].default + assert default is intake_mod.SETTINGS_DOCUMENTS + assert default_columns.SETTINGS_DOCUMENTS == frozenset(intake_mod.SETTINGS_DOCUMENTS) + + +@pytest.mark.parametrize("entry", ["opendox.cli", "opendox.serve"]) +def test_the_standalone_corpus_lists_neither_settings_document(entry, tmp_path) -> None: + """Through each entry point's own default home factory: the bindings + document `model-binding add` writes, and the declarations document, are + not listed, untracked or COMMITTED, nor at a pinned revision. A user's + documents beside them are.""" + import importlib + factory = importlib.import_module(entry)._default_home_factory + root = fresh_repository(PLAIN, tmp_path) + _declare(root) + _write(root, intake_mod.DEFAULT_DECLARATIONS_RELPATH) + for relpath in _USER_DOCUMENTS_BESIDE_SETTINGS: + _write(root, relpath, "title: mine\nsummary: a note of my own\n") + adapter, _ref = factory(root) + expected = _fixture_keys(*_USER_DOCUMENTS_BESIDE_SETTINGS) + assert _listed(adapter, root) == expected, "untracked" + head = _commit_all(root) + tracked = subprocess.run(["git", "-C", str(root), "ls-files"], check=True, + capture_output=True, text=True).stdout.split() + assert binding_mod.DEFAULT_BINDINGS_RELPATH in tracked + assert _listed(adapter, root) == expected, "committed" + assert _listed(adapter, root, head) == expected, "at a pinned revision" + + +def test_a_whole_corpus_check_names_only_what_the_corpus_lists(tmp_path) -> None: + """A committed bindings document, edited after the commit, is not reported + by a whole-corpus check, since the corpus does not list it. A user's + edited document beside it is reported, and the bindings document is + reported when a caller names it (Copilot at openDox-code#76, + r4170556938).""" + from opendox import corpus_adapter + from opendox.runtime import local_git_adapter as lga + root = fresh_repository(PLAIN, tmp_path) + _declare(root) + _write(root, _USER_DOCUMENTS_BESIDE_SETTINGS[0], "title: mine\nsummary: v1\n") + _commit_all(root) + bindings = root / binding_mod.DEFAULT_BINDINGS_RELPATH + bindings.write_text(bindings.read_text(encoding="utf-8") + "# edited\n", + encoding="utf-8") + _write(root, _USER_DOCUMENTS_BESIDE_SETTINGS[0], "title: mine\nsummary: v2\n") + adapter = lga.WorkingTreeCorpus() + corpus = adapter.resolve(corpus_adapter.CorpusRef(name="home", location=str(root))) + assert [f.subject for f in adapter.check(corpus)] == [ + _USER_DOCUMENTS_BESIDE_SETTINGS[0]] + named = (corpus_adapter.DocumentId(corpus="home", + key=binding_mod.DEFAULT_BINDINGS_RELPATH),) + assert [f.subject for f in adapter.check(corpus, named)] == [ + binding_mod.DEFAULT_BINDINGS_RELPATH] + + +def test_a_corpus_told_to_leave_out_nothing_lists_every_file(tmp_path) -> None: + from opendox.runtime import local_git_adapter as lga + root = fresh_repository(PLAIN, tmp_path) + _declare(root) + assert _listed(lga.WorkingTreeCorpus(excluded=()), root) == _fixture_keys( + binding_mod.DEFAULT_BINDINGS_RELPATH) + + +# ---- documents ---- + +@pytest.mark.parametrize(("method", "path", "body"), [ + ("GET", "/snapshot.json", None), + ("GET", f"/source/{_DOCUMENT}", None), + ("GET", f"/source/fixture@main/{_DOCUMENT}", None), + ("GET", "/source", None), # refused alike + ("POST", "/actions/edit", # select-to-edit + {"path": _DOCUMENT, "repository": "fixture", "ref": "main"}), +], ids=["snapshot", "source", "keyed-source", "bare-source", "select-to-edit"]) +def test_documents_answer_alike(postures, method, path, body) -> None: + _alike(postures, method, path, body) + + +# ---- generation ---- + +def test_generation_answers_alike(postures, tmp_path, monkeypatch) -> None: + """The `generate` verb, run over each checkout as a lone openDox, writes the + same snapshot, and it is the one each posture serves. Each run gets the + PATH the two servers started with, with no `omp` on it, so "no model" + is no model here too (Copilot at openDox-code#76 0b0f038e): the + `postures` fixture restores the ambient PATH once its children start.""" + monkeypatch.setenv("PATH", _no_omp_path(tmp_path)) + written = [] + for name in POSTURES: + output = tmp_path / f"{name.replace(' ', '-')}.json" + child, status = run_module(tmp_path / f"generate-{name.replace(' ', '-')}", + "opendox.cli", "generate", + "--repo-root", str(postures[name].repo), + "--repository", "fixture", "--output", str(output)) + assert status == 0, child.stderr_text() + assert child.refused() == [], child.refused() + written.append(output.read_bytes()) + assert written[0] == written[1] + served = _alike(postures, "GET", "/snapshot.json") + assert json.loads(served[0].body) == json.loads(written[0]) + + +# ---- the views ---- + +@pytest.mark.parametrize("path", [ + "/index.html", "/app.js", "/styles.css", "/views/display.js", + "/views/wheel.js", "/views/wheel-model.js", "/views/doc-wheel.js", + "/views/lens.js", "/views/lens-model.js", "/capabilities", +]) +def test_the_views_are_served_alike(postures, path) -> None: + _alike(postures, "GET", path) + + +_VIEW_MODELS = r""" +const W = await import(process.argv[2]); +const L = await import(process.argv[3]); +const snapshot = JSON.parse(await new Promise((resolve) => { + let text = ""; process.stdin.on("data", (c) => { text += c; }); + process.stdin.on("end", () => resolve(text)); +})); +process.stdout.write(JSON.stringify({ + wheel: W.buildWheelModel(snapshot), + lens: L.buildLensModel(snapshot, ""), + documents: L.docSummaries(snapshot), +})); +""" + + +def test_the_wheel_and_the_lens_render_alike(postures, tmp_path) -> None: + """The wheel's and the lens's view-models, built by the browser's own + modules from what each posture serves, are equal.""" + if NODE is None: + pytest.skip("node not available for the wheel and lens models") + views = ROOT / "src" / "opendox" / "web" / "views" + script = tmp_path / "view-models.mjs" + script.write_text(_VIEW_MODELS, encoding="utf-8") + models = [] + for answer in _alike(postures, "GET", "/snapshot.json"): + done = subprocess.run( + [NODE, str(script), (views / "wheel-model.js").as_uri(), + (views / "lens-model.js").as_uri()], + input=answer.body.decode("utf-8"), capture_output=True, text=True, + timeout=60) + assert done.returncode == 0, done.stderr + models.append(json.loads(done.stdout)) + assert models[0]["wheel"]["wheels"], "the wheel built no reel" + assert models[0] == models[1] + + +# ---- sessions ---- + +@pytest.mark.parametrize("verb", ["share-session", "abandon-session", "open-pr"]) +def test_a_session_verb_is_refused_alike_and_writes_nothing(postures, verb) -> None: + """With no session there is nothing to share, abandon or open a pull + request for: each verb is refused alike, and neither checkout changes.""" + before = [postures[name].checkout() for name in POSTURES] + _a_refusal(_alike(postures, "POST", f"/actions/gate/{verb}", {})) + assert [postures[name].checkout() for name in POSTURES] == before + + +_THREAD = ("/workbench/thread?repository=fixture&ref=main&tile_kind=staged" + f"&tile_id=notes-rain-barrel-leak&document={_DOCUMENT}") + + +@pytest.mark.parametrize("path", ["/project-register.json", _THREAD], + ids=["project-register", "thread"]) +def test_a_session_read_answers_alike(postures, path) -> None: + """The project register and a tile's thread are read through openDox's + seams (T084), so each answers standalone, alike.""" + _alike(postures, "GET", path) + + +def test_the_session_controls_are_hidden_alike(postures) -> None: + """Capability honesty (`5920216845` item 1): standalone, `actions.gate` + and `actions.refresh` read false, so the session controls are hidden.""" + for answer in _alike(postures, "GET", "/capabilities"): + actions = json.loads(answer.body)["actions"] + assert actions["gate"] is False and actions["refresh"] is False, actions + + +# ---- saving ---- + +@pytest.mark.parametrize("verb", ["first-edit", "edit-document", "create-document"]) +def test_saving_is_refused_alike_and_writes_nothing(postures, verb) -> None: + """Saving needs a live session, and standalone there is none (`5961651355`): + each save verb is refused alike, and neither checkout changes.""" + before = [postures[name].checkout() for name in POSTURES] + _a_refusal(_alike(postures, "POST", f"/actions/gate/{verb}", + {"path": _DOCUMENT, "content": "# changed\n"})) + assert [postures[name].checkout() for name in POSTURES] == before + + +# ---- the model's own settings surfaces, which answer alike too ---- + +def test_intake_is_not_offered_alike(postures) -> None: + """`5961364221` item 1: standalone, the intake surface answers `offered: + false`, whether or not a binding is declared, with the gate seam's named + reason (T084): no host gate is registered, so no approval can be recorded.""" + from opendox import column_seams + for answer in _alike(postures, "GET", "/workbench/model-intake"): + surface = json.loads(answer.body) + assert surface["offered"] is False, surface + assert surface["reason"] == column_seams.GATE_RECORDS_REFUSAL, surface + + +def test_model_approval_is_refused_alike_and_writes_nothing(postures) -> None: + """`5961364221` item 1: standalone, model approval is refused by the gate + seam's named refusal (T084), alike, and neither checkout changes.""" + from opendox import column_seams + before = [postures[name].checkout() for name in POSTURES] + answers = _alike(postures, "POST", "/actions/workbench/model-approval", + {"binding": "a-provider"}) + _a_refusal(answers, code=serve_wire.DOXBENCH_ERR_APPROVAL_REFUSED) + for answer in answers: + assert json.loads(answer.body)["reason"] == column_seams.GATE_RECORDS_REFUSAL + assert [postures[name].checkout() for name in POSTURES] == before + + +def test_the_document_abstract_is_refused_alike(postures) -> None: + """The document abstract's step-one check refuses once `actions.gate` + reads false, before any reach for a scope (T084).""" + _a_refusal(_alike(postures, "POST", "/actions/workbench/document-abstract", {}), + code=serve_wire.DOXBENCH_ERR_MODEL_CAPABILITY_UNAVAILABLE)