diff --git a/.github/workflows/tooling.yml b/.github/workflows/tooling.yml index 0fb1a3a..29f0f89 100644 --- a/.github/workflows/tooling.yml +++ b/.github/workflows/tooling.yml @@ -6,11 +6,17 @@ on: - 'tools/**' - '.vscode/setup.sh' - '.github/workflows/tooling.yml' + - 'client' + - 'reader' + - '.gitmodules' pull_request: paths: - 'tools/**' - '.vscode/setup.sh' - '.github/workflows/tooling.yml' + - 'client' + - 'reader' + - '.gitmodules' workflow_dispatch: permissions: @@ -21,6 +27,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + submodules: recursive - uses: actions/setup-python@v5 with: python-version: '3.12' @@ -32,3 +40,5 @@ jobs: run: tools/papyrus check tooling - name: Check setup script run: shellcheck .vscode/setup.sh + - name: Check committed reader references + run: tools/papyrus check references diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 1988185..13464d7 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -185,6 +185,57 @@ "panel": "dedicated", "reveal": "always" } + }, + { + "label": "Check reader", + "type": "process", + "command": "${workspaceFolder}/tools/papyrus", + "args": [ + "check", + "reader" + ], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [], + "presentation": { + "panel": "dedicated", + "reveal": "always" + } + }, + { + "label": "Test reader", + "type": "process", + "command": "${workspaceFolder}/tools/papyrus", + "args": [ + "test", + "reader" + ], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [], + "presentation": { + "panel": "dedicated", + "reveal": "always" + } + }, + { + "label": "Check all", + "type": "process", + "command": "${workspaceFolder}/tools/papyrus", + "args": [ + "check", + "all" + ], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [], + "presentation": { + "panel": "dedicated", + "reveal": "always" + } } ], "inputs": [ diff --git a/AGENTS.md b/AGENTS.md index 5446d16..1a94993 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,8 +27,8 @@ Flutter is pinned in `.fvmrc` to the client CI version; use `tools/flutter` and `tools/dart` rather than the machine's potentially newer SDK. - `tools/papyrus doctor`: tools, SDK, local configuration, submodules, GitHub auth. -- `tools/papyrus deps client|server`: locked dependency setup. -- `tools/papyrus check client|server|tooling`: non-mutating quality checks. +- `tools/papyrus deps client|reader|server|all`: locked dependency setup. +- `tools/papyrus check client|reader|server|references|tooling|all`: non-mutating quality checks. - `tools/papyrus test client -- test/path_test.dart`: focused Flutter test. - `tools/papyrus test server -- tests/services/test_sync.py`: focused pytest run. - `tools/papyrus run client|server`: development processes. @@ -60,4 +60,4 @@ contract decisions with both implementers and run database tests serially. Follow the existing design tokens and e-ink motion preferences. Library operations must work offline and remain isolated across guest, user, and server profiles. Treat stored reading positions and user media as durable data. See -`DEVELOPMENT.md` for setup, measured baseline and known gaps. +`DEVELOPMENT.md` for setup, checks and integration workflow. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 17cb304..e544644 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -3,8 +3,8 @@ Papyrus is a workspace of independent Git submodules. The client is Flutter with Provider, SQLite/PowerSync, platform adapters and a Git-pinned EPUB/PDF reader. The server is FastAPI with async SQLAlchemy, Pydantic, Alembic, PostgreSQL and a -self-hosted PowerSync service. Inspect the source for implemented capabilities; -some README feature lists and setup links are ahead of the current checkout. +self-hosted PowerSync service. Product requirements describe planned capabilities; component READMEs and source +describe current behavior. ## Setup @@ -49,11 +49,14 @@ directory. All subprocesses use the appropriate component directory. | `tools/papyrus doctor` | Inspect tools, config, SDK, repos, Docker and GitHub authentication | | `tools/papyrus sdk` | Install/link the FVM version from `.fvmrc` | | `tools/papyrus deps client` | Install client dependencies with its committed lock | +| `tools/papyrus deps reader` | Install reader dependencies with its committed lock | | `tools/papyrus deps server` | Install server/dev dependencies with the uv lock | | `tools/papyrus check client` | Non-writing Dart format check, Flutter analysis, four web-bootstrap tests | | `tools/papyrus check server` | Ruff lint, Ruff format check, strict Mypy | -| `tools/papyrus check tooling` | Eight CLI regression tests and ShellCheck | -| `tools/papyrus check all` | All three check groups above; reports independent failures | +| `tools/papyrus check reader` | Formatting, worker asset drift, library and example analysis | +| `tools/papyrus check references` | Match committed workspace reader and client dependency revisions | +| `tools/papyrus check tooling` | CLI regression tests and ShellCheck | +| `tools/papyrus check all` | Tooling, reference consistency, client, reader and server; reports independent failures | | `tools/papyrus test client -- test/auth/token_store_test.dart` | Focused Flutter tests; pass options after `--` | | `tools/papyrus test client -- --coverage` | Full client suite and coverage | | `tools/papyrus test server -- tests/services/test_sync.py` | Focused backend tests | @@ -63,9 +66,18 @@ directory. All subprocesses use the appropriate component directory. Use `tools/flutter` and `tools/dart` when a command is not covered by the CLI. These wrappers fail if the pinned SDK is missing. They do not silently use global Flutter. -Reader `deps`, `check`, and `test` commands are also supported; its library lockfile -is ignored, so reader dependency setup resolves its declared constraints normally. -Website and Sphinx checks remain in their own repos. +Reader `deps`, `check`, and `test` commands also use the pinned SDK and committed +lockfile. Run the reader browser suite after UI, layout, focus, or worker changes +as described in `reader/docs/validation.md`. + +Website checks use `npm ci && npm run build` in `website/`. Documentation checks +use `uv sync --locked --extra dev && make build` in `docs/` (Graphviz required). + +For a joint reader/client change, validate a local reader using an ignored +`client/app/pubspec_overrides.yaml`, then remove the override and pin the published +reader commit in `client/app/pubspec.yaml`. Regenerate the client lock and update +the workspace's reader and client gitlinks together. The reference check compares +committed gitlinks and committed dependency files, independently of local overrides. Server pytest fixtures drop and recreate test tables. The CLI checks for a separate local database named `*_test` or `test_*` and locks overlapping CLI server test runs. @@ -104,8 +116,7 @@ in disjoint files, then use the contract reviewer. Run database tests serially. `.codex/config.toml` starts the official Dart tooling MCP through `tools/dart-mcp`, which uses the pinned SDK. The launcher finds the workspace from the root or a -nested component directory. Protocol initialization and enumeration of **25 tools** -were verified from both the root and `client/app`. These include analysis, tests, +nested component directory. It provides analysis, tests, hot reload, widget inspection and runtime-error inspection. Restart the Codex session to load new project MCP/agent configuration if needed. @@ -119,45 +130,3 @@ The layout follows [Codex skills](https://learn.chatgpt.com/docs/build-skills), and [Dart MCP setup](https://docs.flutter.dev/ai/get-started). FVM's [project configuration](https://fvm.app/documentation/getting-started/configuration) keeps SDK selection separate from the global toolchain. - -## Verified baseline — 2026-10-03 - -| Scope | Result | -| --- | --- | -| Client lockfile | Five transitive versions normalized to the CI SDK; locked installation succeeds | -| Client formatting | 449 Dart files checked, no changes required | -| Client analysis | No issues | -| Client tests | 1,387 passed; 19 skipped | -| Web bootstrap | Four tests passed | -| Web build | Release compilation and Wasm dry run succeeded | -| Reader tests | 93 passed; 21 skipped in the sibling checkout | -| Server lint/types | Ruff lint and Mypy pass (138 source files) | -| Server tests | 340 passed; two provider smoke tests excluded | -| Server formatting | Five pre-existing files need formatting; check correctly fails | -| Tooling | Eight regression tests, ShellCheck and skill frontmatter validation pass | -| Dart MCP | Initialized successfully; 25 tools enumerated from root and nested cwd | -| Local platforms | Flutter doctor finds Android SDK, Xcode, Chrome and macOS target | -| GitHub CLI | Installed; account sign-in remains pending | - -The five server formatting files are `papyrus/models/powersync_demo.py`, -`tests/api/routes/test_auth_sandbox.py`, `test_powersync_sandbox.py`, -`tests/integration/test_auth_smoke.py`, and `tests/services/test_auth.py`. -They were not reformatted as part of tooling setup. Local verification logs are -ignored under `.local/tooling/`. - -Live cross-device PowerSync and external OAuth/SMTP smoke tests were not run. -Native release builds and Windows/Linux builds were not run. The global Flutter -SDK remains newer; Flutter doctor may report that PATH mismatch while project -commands and VS Code use FVM correctly. - -The client CI now enforces its lockfile and checks formatting without writing. -The workspace tooling workflow runs the CLI regressions and ShellCheck on relevant -pushes and pull requests; it does not require the application submodules or services. - -The server README links to auth, acquisition and PowerSync guides absent from -this checkout. Its existing `.env.example`, tests, services, and -`docs/opds-relay.md` are usable sources. The client reader dependency is pinned to -`08a5161b9d00eb73581f74ce087b9ad6c1568ca7`, while the sibling reader checkout is at a -different revision. Editing it alone does not change client behavior. For a joint -reader change, use an ignored `client/app/pubspec_overrides.yaml` with a local path -override during validation, then coordinate a deliberate revision update. diff --git a/README.md b/README.md index 6df3bd0..1d458c2 100644 --- a/README.md +++ b/README.md @@ -80,8 +80,8 @@ Use the workspace CLI from the repository root: ```bash tools/papyrus doctor -tools/papyrus check client -tools/papyrus check server +tools/papyrus deps all +tools/papyrus check all tools/papyrus test client -- test/auth/token_store_test.dart tools/papyrus test server -- tests/services/test_sync.py ``` @@ -90,4 +90,4 @@ It uses the Flutter version pinned in `.fvmrc` and the server's uv lockfile. The same checks and test suites are available as VS Code tasks. See [development tooling](DEVELOPMENT.md) for the project skills, agent roles, -Dart MCP integration, setup instructions, and verified baseline. +Dart MCP integration, and setup instructions. diff --git a/client b/client index 513d177..f31e41b 160000 --- a/client +++ b/client @@ -1 +1 @@ -Subproject commit 513d1774670be1a617affa5249fc06a65d98b1ce +Subproject commit f31e41b9505ef6376fd206ef2bf77eea10a5f961 diff --git a/docs b/docs index 57048e7..ca816d3 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit 57048e7d60c2aaa2bc12557bd9c1e22c2a206f19 +Subproject commit ca816d3bddadf6859047f8583877b8d3df9cf841 diff --git a/reader b/reader index 22b08f2..c870bf4 160000 --- a/reader +++ b/reader @@ -1 +1 @@ -Subproject commit 22b08f2a61b1f33969ee9797b6e5acddbde0fc00 +Subproject commit c870bf4df98b01530c6b6e6ae5cd59bd5ea58cc3 diff --git a/server b/server index 71b21c6..d01f219 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit 71b21c65833891826427a568a6d990db964b5962 +Subproject commit d01f2196c006824dd4be494b1ed43ff3223cd700 diff --git a/tools/check_references.py b/tools/check_references.py new file mode 100644 index 0000000..1363cbf --- /dev/null +++ b/tools/check_references.py @@ -0,0 +1,35 @@ +"""Check the committed workspace reader pin against the committed client lock.""" + +import re +import subprocess +from pathlib import Path + + +def git(root: Path, *args: str) -> str: + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def check(root: Path) -> None: + client = git(root, "ls-tree", "HEAD", "client").split()[2] + reader = git(root, "ls-tree", "HEAD", "reader").split()[2] + manifest = git(root / "client", "show", f"{client}:app/pubspec.yaml") + lock = git(root / "client", "show", f"{client}:app/pubspec.lock") + for source, field in [(manifest, "ref"), (lock, "resolved-ref")]: + block = re.search(r"(?m)^ papyrus_reader:\n((?: .*\n?)+)", source) + pin = ( + re.search(rf'{field}:\s*["\x27]?([a-f0-9]{{40}})\b', block[1]) + if block + else None + ) + if pin is None or pin[1] != reader: + raise ValueError( + f"Committed client {field} must match workspace reader {reader}" + ) + print(f"Committed reader references agree: {reader}") + + +if __name__ == "__main__": + try: + check(Path(__file__).resolve().parent.parent) + except (IndexError, ValueError, subprocess.CalledProcessError) as error: + raise SystemExit(f"Reader reference check failed: {error}") from error diff --git a/tools/papyrus b/tools/papyrus index 608e8e3..5bf20d7 100755 --- a/tools/papyrus +++ b/tools/papyrus @@ -12,6 +12,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parent.parent CLIENT = ROOT / "client/app" SERVER = ROOT / "server" +READER = ROOT / "reader" def run(args, cwd=ROOT, env=None): @@ -77,11 +78,18 @@ def checks(component): "--set-exit-if-changed", "lib", "test", + "tool", + "example/lib", + "example/test", ], - ROOT / "reader", + READER, ), - ([sdk_command("flutter"), "analyze", "--no-pub"], ROOT / "reader"), + (["bash", "tool/build_epub_worker.sh", "--check"], READER), + ([sdk_command("flutter"), "analyze", "--no-pub"], READER), + ([sdk_command("flutter"), "analyze", "--no-pub"], READER / "example"), ] + if component == "references": + return [([sys.executable, "tools/check_references.py"], ROOT)] return [ ( [sys.executable, "-m", "unittest", "discover", "-s", "tools/tests", "-v"], @@ -92,7 +100,11 @@ def checks(component): def check(component): - components = ["tooling", "client", "server"] if component == "all" else [component] + components = ( + ["tooling", "references", "client", "reader", "server"] + if component == "all" + else [component] + ) failed = [] for item in components: for args, cwd in checks(item): @@ -226,7 +238,7 @@ def main(argv=None): ) quality.add_argument( "component", - choices=["client", "server", "reader", "tooling", "all"], + choices=["client", "server", "reader", "tooling", "references", "all"], default="all", nargs="?", ) @@ -252,7 +264,7 @@ def main(argv=None): return test(options.component, args) if options.command == "deps": selected = ( - ["client", "server"] + ["client", "reader", "server"] if options.component == "all" else [options.component] ) @@ -270,7 +282,7 @@ def main(argv=None): sdk_command("flutter"), "pub", "get", - *(["--enforce-lockfile"] if item == "client" else []), + "--enforce-lockfile", ], directory, ) diff --git a/tools/tests/test_papyrus.py b/tools/tests/test_papyrus.py index ffb779a..a52f897 100644 --- a/tools/tests/test_papyrus.py +++ b/tools/tests/test_papyrus.py @@ -78,6 +78,27 @@ def test_client_format_check_does_not_write_files(self): self.assertIn("--set-exit-if-changed", args) self.assertEqual(cwd, cli.CLIENT) + def test_all_checks_include_reader_and_reference_gate(self): + with patch.object(cli, "checks", return_value=[]) as checks: + self.assertEqual(cli.check("all"), 0) + self.assertEqual( + [call.args[0] for call in checks.call_args_list], + ["tooling", "references", "client", "reader", "server"], + ) + + def test_all_flutter_dependencies_enforce_locks(self): + with ( + patch.object(cli, "sdk_command", return_value="flutter"), + patch.object(cli, "run", return_value=0) as run, + ): + self.assertEqual(cli.main(["deps", "all"]), 0) + self.assertEqual( + [call.args[1] for call in run.call_args_list], + [cli.CLIENT, cli.READER, cli.SERVER], + ) + for call in run.call_args_list[:2]: + self.assertIn("--enforce-lockfile", call.args[0]) + def test_sdk_absent_does_not_fall_back_to_global_flutter(self): with ( tempfile.TemporaryDirectory() as directory, diff --git a/tools/tests/test_references.py b/tools/tests/test_references.py new file mode 100644 index 0000000..187da6a --- /dev/null +++ b/tools/tests/test_references.py @@ -0,0 +1,107 @@ +"""Validate recorded dependency pins independently of dirty working trees.""" + +import importlib.util +import subprocess +import tempfile +import unittest +from pathlib import Path + +spec = importlib.util.spec_from_file_location( + "references", Path(__file__).resolve().parents[1] / "check_references.py" +) +references = importlib.util.module_from_spec(spec) +spec.loader.exec_module(references) + + +class CommittedReferencesTest(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + self.command(self.root, "init", "-q") + for name in ("client", "reader"): + self.command(self.root / name, "init", "-q") + (self.root / "reader/example").mkdir() + (self.root / "reader/example/fixture").write_text("reader") + self.commit(self.root / "reader") + self.reader = self.command(self.root / "reader", "rev-parse", "HEAD") + (self.root / "client/app").mkdir() + self.pin(self.reader) + self.commit(self.root / "client") + self.record() + + def command(self, root, *args): + root.mkdir(parents=True, exist_ok=True) + return subprocess.check_output( + ["git", "-C", str(root), *args], text=True, stderr=subprocess.DEVNULL + ).strip() + + def commit(self, root): + self.command(root, "add", ".") + self.command( + root, + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "-qm", + "fixture", + ) + + def pin(self, revision): + (self.root / "client/app/pubspec.yaml").write_text( + f"dependencies:\n papyrus_reader:\n git:\n ref: {revision}\n" + ) + (self.root / "client/app/pubspec.lock").write_text( + f'packages:\n papyrus_reader:\n description:\n resolved-ref: "{revision}"\n' + ) + + def record(self): + for name in ("client", "reader"): + revision = self.command(self.root / name, "rev-parse", "HEAD") + self.command( + self.root, + "update-index", + "--add", + "--cacheinfo", + "160000", + revision, + name, + ) + self.command( + self.root, + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "-qm", + "workspace", + ) + + def test_dirty_dependency_files_do_not_override_committed_agreement(self): + self.pin("f" * 40) + references.check(self.root) + + def test_new_committed_client_requires_matching_reader_pin(self): + self.pin("f" * 40) + self.commit(self.root / "client") + self.record() + with self.assertRaisesRegex(ValueError, "must match workspace reader"): + references.check(self.root) + + def test_lock_drift_is_rejected_even_when_manifest_agrees(self): + (self.root / "client/app/pubspec.lock").write_text( + 'packages:\n papyrus_reader:\n description:\n resolved-ref: "' + + "f" * 40 + + '"\n' + ) + self.commit(self.root / "client") + self.record() + with self.assertRaisesRegex(ValueError, "resolved-ref must match"): + references.check(self.root) diff --git a/website b/website index 50e7561..d402479 160000 --- a/website +++ b/website @@ -1 +1 @@ -Subproject commit 50e75611c5ea79fb98a6acea77b6f2fd4efa454b +Subproject commit d402479c21f55c0e1b87900de3de6878e584877e