diff --git a/AGENTS.md b/AGENTS.md index 4b7ac44..c1caaf0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,13 @@ platform behavior remains owned by the corresponding module repository. ## Working Rules +- Start repeated workflows with `./devkit commands`; use `context --changed`, + `check --profile quick --changed --dry-run`, and `review MODULE` instead of + reconstructing repository/check inventories. See `docs/operations/DEVKIT.md`. +- Prefer compact check receipts (`status`, `summary`, `logs`) to repeated full + log dumps. `check --profile full` retains the required focused gate; targeted + profiles or cached results do not waive required verification or approvals. + - Treat Gitea issues as the canonical backlog and state log. - Preserve optional module boundaries and use Core contracts or capabilities for integrations. - Prefer targeted checks before full workspace scans. diff --git a/README.md b/README.md index dedc660..200096d 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,17 @@ installed modules/connectors discovered by core. ## Common Commands +For repeated development and review workflows, start with the unified command +suite. [Developer command guide](docs/operations/DEVKIT.md) documents profiles, +evidence, safe Git/release operations and reuse in other projects. + +```sh +./devkit commands +./devkit context --changed +./devkit check --profile quick --changed --dry-run +./devkit review campaign +``` + Create the whole-product development virtualenv in this meta repository: ```sh diff --git a/devkit b/devkit new file mode 100755 index 0000000..a5a7a5d --- /dev/null +++ b/devkit @@ -0,0 +1,12 @@ +#!/bin/sh +# One stable entry point; no environment activation, installation or server startup. +set -eu +DEVKIT_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +if [ -n "${PYTHON:-}" ]; then + DEVKIT_PYTHON=$(command -v "$PYTHON") || { echo "devkit: configured PYTHON is unavailable" >&2; exit 127; } +elif [ -x "$DEVKIT_ROOT/.venv/bin/python" ]; then + DEVKIT_PYTHON="$DEVKIT_ROOT/.venv/bin/python" +else + DEVKIT_PYTHON=$(command -v python3) +fi +exec "$DEVKIT_PYTHON" "$DEVKIT_ROOT/tools/devkit/devkit.py" "$@" diff --git a/docs/README.md b/docs/README.md index f3778b3..4f943cd 100644 --- a/docs/README.md +++ b/docs/README.md @@ -64,6 +64,7 @@ in `govoplan-records/docs/EAKTE_ARCHITECTURE.md`. | Evidence collection and promotion | [Target Maturity Evidence Runbook](operations/TARGET_MATURITY_EVIDENCE_RUNBOOK.md) | | Package publication and consumption | [Package Registry Releases](operations/PACKAGE_REGISTRY_RELEASES.md) | | Release-console operation | [Release Console](operations/RELEASE_CONSOLE.md) | +| Repeated development, verification and evidence commands | [Developer Command Suite](operations/DEVKIT.md) | | Module compatibility and install behavior | [Module Contracts and Installs](operations/MODULE_CONTRACTS_AND_INSTALLS.md) | | Security-audit toolchain | [Security Audit](operations/SECURITY_AUDIT.md) | @@ -75,6 +76,8 @@ in `govoplan-records/docs/EAKTE_ARCHITECTURE.md`. meta, module, deployment, and website content. - [Gitea Issues](project/GITEA_ISSUES.md) defines labels, templates, import, and state-update conventions. +- [UI Review Program](project/UI_REVIEW_PROGRAM.md) defines the principle-led, + per-module review process and links the canonical Gitea review inventory. ## Evidence And Archive diff --git a/docs/operations/DEVKIT.md b/docs/operations/DEVKIT.md new file mode 100755 index 0000000..8cc9951 --- /dev/null +++ b/docs/operations/DEVKIT.md @@ -0,0 +1,300 @@ +# Developer command suite + +`./devkit` is the maintained entry point for repeated development work. It uses +ordinary local programs, not an AI service. People, CI and coding agents use the +same commands and results. It composes existing GovOPlaN checks and release +services rather than defining parallel product rules. + +## Start here + +From the Meta checkout: + +```sh +./devkit commands +./devkit doctor --repo core +./devkit context --changed +./devkit check --profile quick --repo campaign --dry-run +./devkit coverage --profile ui --repo campaign +./devkit check --profile quick --repo campaign +./devkit latest +./devkit review campaign +``` + +Use `--help` on each command. `--json` (or `--format json`) returns structured +results; the default summary intentionally omits full logs and source contents. +Global options work before or after the command. `--workspace-root` is the parent +directory of the registered repositories, not the Core checkout. Unknown +repository filters fail rather than silently widening/narrowing scope. + +The launcher prefers Meta's `.venv/bin/python`, or the explicit `PYTHON` +executable, with `python3` as the no-venv fallback. Checks resolve `NODE` and +`NPM` from explicit environment settings or `PATH`; no username-specific Node +installation path is required. Doctor reports repairs but never installs +packages, changes configuration, kills an occupied port or starts a server. + +## Command contracts + +| Command | Purpose | Effects | +| --- | --- | --- | +| `context [--changed] [--repo NAME]` | Offline Git state, instruction/documentation paths and review links | Reads only; upstream counts are not freshly fetched | +| `doctor [--repo NAME] [--profile PROFILE]` | Interpreter, dependency, browser and test-resource preflight | Reads only; repair suggestions are not executed | +| `check --profile PROFILE` | Registered checks with explanations, bounded logs and a receipt | Runs trusted tests/builds; `--dry-run` only plans | +| `coverage --profile PROFILE [--repo NAME]` | Declared suite dispositions and explicit coverage limits | Reads only; not execution evidence | +| `runs [--limit 10] [--before RUN]`, `latest` | Discover recent runs without looking up state-directory paths | Reads only; malformed latest evidence is not replaced with an older pass | +| `status RUN`, `summary RUN` | Stage counts and failure log locations | Reads only | +| `logs RUN --stage STAGE --tail 40 [--final-only]` | Provisional live output or a hash-verified final stage log | Reads only; provisional output never proves a pass | +| `resume RUN` | Replan the previous check selection and reuse eligible successful stages | Runs remaining checks, creating a new receipt | +| `recover RUN [--apply]` | Recover an abandoned check-run record after acquiring its OS lock | No test replay; apply changes only the local record | +| `review MODULE` | Source inventory, principle revision, issue links, check plan and walkthrough checklist | Does not perform or complete a module review | +| `docs audit [--repo NAME] [--changed]` | Owning manifest/help/translation checks and visible-label gaps | Local checks only; never translates or edits content | +| `issues note …` | Preview and explicitly append deduplicated evidence | Remote comment creation only with `--apply` | +| `release …` | Existing durable release planning/execution/recovery | Explicit `--apply`, request IDs and existing step confirmations | +| `git …` | Frozen, explicit-path maintenance commit and exact branch push | Preview first; mutations require `--apply`; no stage-all, force or tags | + +Git maintenance is a separate, selected-path workflow; see +[Git maintenance](DEVKIT_MAINTENANCE.md). Issue notes and module bundles are +documented in [Evidence and review commands](DEVKIT_EVIDENCE.md). The complete +release command syntax, limitations and examples are in +[Headless release operations](DEVKIT_RELEASE.md). + +## Check profiles and their limits + +- `quick`: existing manifest/interface invariants, import/help guards and selected + module source/structure tests. It does not compile component suites or run the + full browser matrix. +- `ui`: quick/source contracts plus the shared Core component batch, compiled + once in an isolated directory. This is not a full visual/usability review. +- `backend`: existing manifest/interface checks and selected repository backend + test suites. Shared backend-test state is serialized. +- `full`: the canonical `tools/checks/check-focused.sh`, including optional-module + build permutations and integrated browser conformance. Repository filters do + not reduce this required cross-module completion gate. + +`--changed` includes uncommitted work, commits ahead of the locally cached +upstream and repositories with commits but no configured upstream. +Missing/unreadable repositories remain visible as errors. For the +GovOPlaN profiles, Core or Meta changes conservatively select every registered +consumer; other changes expand through declared interface consumers. This is +not complete semantic dependency analysis. Use the full gate for cross-module +completion as required by `AGENTS.md`. + +The module catalog reuses declared direct-Node tests and established structural +script names. Arbitrary shell chains are not guessed or rewritten. Unsupported +declared scripts remain visible with a reason; only exact known Core component +aliases are covered by the shared component runner. Invalid package metadata is +an error, not an empty test inventory. + +`coverage` inventories declared suites as `planned`, `covered_elsewhere`, +`excluded` or `unsupported`, naming their covering stage where applicable. The +same inventory accompanies check dry runs and saved receipts. **Full means the +canonical focused gate, not every test in every package.** Its inventory follows +explicit canonical commands conservatively; it does not infer arbitrary nested +shell commands or npm hooks. Add new deterministic checks to the owning +package/canonical gate and the catalog's tests as appropriate. Coverage planning +does not execute the scripts it inventories or assert that they passed. See +[Coverage and validation details](DEVKIT_COVERAGE.md) for examples and boundaries. + +Independent checks can run with `--jobs 1..8` (default 2). Shared resources use +OS locks for the current user, including across runs with different evidence +directories. Browser/port locks are shared across that user's workspaces. Full runs +reserve the shared WebUI/backend/browser resources. Standalone Core component +commands use isolated temporary build directories; `npm run test:components -- +page-layout documentation-help` compiles once for that batch. Managed temporary +output is cleaned; no user worktree is reset or restored. + +## Evidence, interruption and safe reuse + +A check announces its run ID and saves a `preparing` receipt before source and +environment fingerprinting. Progress on stderr shows elapsed time, stage counts +and active stages; it also distinguishes `checking` from final snapshot +verification (`finalizing`). `--json` keeps stdout as one final JSON result and +emits structured progress events on stderr; `--quiet` suppresses these events. +Use `latest`, `status RUN` and `logs RUN --stage STAGE` from another terminal +while a check runs. Counts are stages, not a guessed percentage of test effort. + +Live snapshots are private, bounded, explicitly provisional and not accepted for +reuse or issue evidence. A completed stage log can be hash-verified before the +whole run finishes, but only the final run snapshot verifies the overall result. +`--final-only` rejects unfinished logs. Run history supports cursor pagination +(up to 100 rows per page), bounds directory scanning, and never deletes evidence. +An explicitly selected `--project` also filters run discovery by project file. + +Default records are private below +`$XDG_STATE_HOME/govoplan/devkit/workspace-/runs//` (fallback +`~/.local/state`). `--state-dir` selects another base; workspace scoping still +applies. Status changes are atomically persisted with restrictive file modes; +symlinked evidence paths are rejected. A record contains source/environment/plan +fingerprints, stage reasons, status, exit code, timing and log identities. + +Stages distinguish pending, running, passed, failed, timed out, blocked, skipped, +stale and interrupted. A skipped or unexecuted stage is never a pass. A source or +environment change during verification marks the overall result **stale**, even +when individual subprocesses returned zero. Such a result is not passing evidence. + +`resume RUN` replans the current selection and compares each phase independently. +Matching **verified checkpoints** are reused; changed/new phases and phases +without a checkpoint run again. A checkpoint binds the exact command and working +directory, declared repository inputs, transitive data-dependency identities, +devkit implementation/schema, tool/dependency metadata and environment. Logs must +retain their recorded hashes; a missing or changed cached log fails closed. +The summary reports how many checkpoints were reused; JSON records `input_scope`, +`cache_key`, `checkpoint_verified`, `reused_from` and the reuse/rerun reason. + +Each checkpoint is saved while its resource locks remain held, only after exit +zero, a verified final log, and matching before/after inputs and environment. +A phase that changes its own inputs is stale, even if another phase later restores +those bytes. Final verification rechecks all passed/reused phases against current +inputs before the new aggregate can pass. An interrupted, failed or stale run may +donate an independently verified matching checkpoint, but never becomes passing +evidence itself. Old receipts remain readable: legacy monolithic logs and receipts +without these checkpoints cannot be retroactively split or reused as verified phases. + +After a hard crash, `recover RUN` shows the abandoned owner and guidance. Inspect the +recorded commands and manually stop any surviving test/build processes before +using `recover RUN --apply --confirm-processes-stopped`. The command verifies the +run lock is free before marking the record interrupted. A free parent lock alone +does **not** prove children stopped after a hard kill. Recovery does not replay +processes or infer that a partially completed operation succeeded. + +### Canonical phases and narrower inputs + +The full gate has seven explicit phases: `preflight`, `tooling`, `backend`, +`core-ui`, `module-builds`, `browser`, and `module-ui`. Devkit stage IDs are +`focused.`. The authoritative Bash bodies remain in +`tools/checks/check-focused.sh`; the bounded metadata in +`tools/checks/focused-phases.json` names their order and resources. The direct +shell command still runs every phase in the original fail-fast order. For inspection: + +```sh +tools/checks/check-focused.sh --list-phases +./devkit check --profile full --jobs 1 +./devkit resume RUN_ID --jobs 1 +``` + +For example, a browser failure no longer forces successful backend/build phases +to run again if their inputs still match. Standalone `--phase browser` is available +for diagnostics, but is not a substitute for the complete gate or its receipt. + +Scopes are whole repositories, not inferred file globs. A trusted check may declare +`"inputs": {"repos": ["canonical-repository-name"]}`; no declaration means the whole +registered workspace. Dry runs show that boundary. Native source-discovery checks +retain broad scopes where cross-module dependencies cannot safely be narrowed. +Changes outside an explicit scope do not invalidate it, but tool/environment +changes remain conservatively global. Scope declarations are a correctness +contract: include every source repository that the check reads, not only its cwd. +Native discovery ownership/presence is rechecked at phase boundaries, so a newly +appearing module or WebUI directory invalidates the in-flight environment identity. +Unregistered native sibling sources disable reuse with an explicit coverage note; +register them before relying on cached results. Ordinary generated files inside +existing source directories do not change this directory-shape identity. + +Every snapshot rereads repository presence, HEAD, index/flags and tracked plus +non-ignored untracked membership, including assume-unchanged files. File hashes +use a bounded **in-process-only** memo with file identity/ctime/mtime/size checks +before and after an open file descriptor; no persistent mtime cache is trusted. +In-repository regular-file symlink targets are included; escaping or directory +symlinks fail closed. Ignored files, undeclared external data and service state +are not source evidence and need explicit verification. + +Environment probes stream bounded regular-file contents and reject concurrent +replacement, growth and symlink retargeting. Stable venv executable and dependency +directory symlinks remain supported without changing the executable path; absent +optional metadata remains optional. Environment identities are not persistently +cached and remain deliberately global. + +These checkpoints cache **verification results, not output artifacts**. The full +gate's phases were audited so later phases do not require an earlier phase's +retained build output. A custom setup/build that produces files consumed later +must declare `"reuse": "never"`; its consumers must use `deps`. Until output +manifests/restoration exist, do not assume a cached successful build recreates +deleted ignored artifacts. + +Check output is drained without unbounded memory growth. Stored output retains +the beginning and actual final tail within an 8 MiB bound per stage, with a small +truncation marker and an omitted-byte count; output truncation does not change +the subprocess exit result. `logs` exposes at most 200 lines and 16 KiB. Live +snapshots retain at most 64 KiB after redaction. Incomplete live lines and cut +retention-boundary lines are withheld to avoid exposing fragments of secrets; +very long single-line output may therefore be absent from live views. +Known environment credentials and common authorization patterns are +redacted as display hygiene. **Never pass credentials as check arguments or print +them from tests.** Redaction is not a general secret-classification guarantee. +JSON command output is a redacted presentation; the receipt file at the reported +path is the canonical local record. Cancellation and deadlines terminate the +owned process group; a parent leaving running descendants does not pass. +During source/environment fingerprinting, cancellation is observed between +probes; the current bounded probe may finish first. Cancellation during final +verification is recorded as interrupted, never as a passing run. +Deliberately detached processes are outside that group: this is not a sandbox. + +These local hash-bound records detect accidental changes; they are not signed +attestations, a security certification, proof of complete test coverage, or +permission to publish. External service state and undeclared dependencies still +need explicit verification. No automated result closes a Gitea issue or marks a +module reviewed. Detailed logs remain local unless deliberately shared. Old run +directories are not automatically deleted; review retention before removing any +evidence referenced by an issue. + +## Reuse in another project + +The runner, repository context, doctor and evidence primitives are usable with an +explicit local JSON project manifest. The example at +`tools/devkit/examples/project.json` registers ordinary Python tests and a Git +whitespace check; the format is described by +`tools/devkit/project.schema.json`. + +```sh +/path/to/govoplan/devkit --workspace-root /path/to/project \ + --project /path/to/project/devkit-project.json check --profile quick --dry-run +``` + +Repository paths and check working directories must remain inside the selected +workspace. Check arguments are arrays, not shell-evaluated strings. The supported +tool placeholders are `{python}`, `{node}`, `{npm}` and `{workspace}`. Check +dependencies form an acyclic graph; resources serialize incompatible tasks. +`deps` declares actual data dependencies: consumers rerun whenever a dependency +runs again. `after` declares only fail-fast execution ordering: a failed predecessor +skips the follower, but a successfully rerun independent predecessor does not +invalidate the follower's inputs. Both edge types are validated together for +cycles, missing IDs and overlap. `reuse` is `verified` by default or `never` for +setup/output-producing checks. Repository `inputs` names are canonical, unique, +nonempty and validated even for unselected checks. +The complete custom manifest is validated, including unselected checks/profiles: +unknown fields, misspellings, invalid bounds/types and unresolved dependencies +fail early. This prevents silently ignored resource or timeout declarations. + +Portable `doctor` always requires its Python runtime, plus tools needed by the +selected checks and their dependencies and tools explicitly configured in the +manifest. Without `--profile`, all declared profiles are considered. Unused +Node/npm are marked `not_required` and do not block Python-only projects. Declare +indirect tool dependencies explicitly: arbitrary script contents are not +analyzed. Native GovOPlaN retains its Python/Node/npm requirements. +Only use project manifests and scripts you trust: running a registered test is +ordinary code execution, not a sandbox. Platform-specific release and Docs +commands reject generic project manifests; put another project's checks in its +own profiles rather than pretending its release policy is GovOPlaN's. + +The initial runtime targets POSIX environments with Python 3.11+ and OS advisory +locks; it does not claim Windows support. No globally installed service or Codex +plugin is needed. Keep this implementation versioned and reuse it; avoid copying +diverging helper implementations into each project. + +Devkit is not a complete build system: it does not infer semantic dependencies, +restore output artifacts, sandbox checks, or share signed remote caches. Repository +scopes intentionally stop short of file/glob narrowing; environment identities +remain global. These are explicit boundaries, not claims of exhaustive coverage. + +## Development and conformance + +```sh +./.venv/bin/python -m pytest -q tests/test_devkit_*.py +./.venv/bin/python -m pytest -q tests/test_focused_phases.py +node --test tests/test-devkit-display-labels.mjs +./.venv/bin/python -m unittest tests.test_documentation_structure +``` + +The focused gate includes the Python command-suite regression tests. Fixture +tests use temporary repositories/processes and mocked issue/release transports; +they do not commit user work, send mail, publish artifacts or change live issues. +Documentation is part of each command change: update `--help`, this guide and the +corresponding targeted contract tests together. diff --git a/docs/operations/DEVKIT_COVERAGE.md b/docs/operations/DEVKIT_COVERAGE.md new file mode 100755 index 0000000..9a3e97a --- /dev/null +++ b/docs/operations/DEVKIT_COVERAGE.md @@ -0,0 +1,72 @@ +# Check configuration and suite coverage + +`devkit coverage` explains what a selected plan intends to run. It does not run +tests, start servers, or report successful verification: + +```sh +./devkit coverage --profile quick --repo campaign +./devkit coverage --profile ui --repo portal --json +./devkit coverage --profile full --json +./devkit --project /path/to/project.json coverage --profile quick +``` + +The inventory accounts for declared `test` and `test:*` scripts in each +registered repository's root and `webui/package.json`, discovered UI structural +checks, and explicitly configured project checks. It is a suite inventory, not +an enumeration of every test function or recursive dependency. Unselected suites +remain visible; every row has a disposition and reason: + +| Disposition | Meaning | +| --- | --- | +| `planned` | A selected stage directly invokes the suite. | +| `covered_elsewhere` | An exact equivalent invocation or an explicitly selected shared batch owns it; the covering stage is named. | +| `excluded` | The suite is outside the actual selected plan. | +| `unsupported` | Scoped discovery cannot safely interpret or locate the command; no command is guessed or executed. | + +`full` means the existing canonical `check-focused.sh` gate. It does **not** +mean every package script or every component suite. Coverage reads the current +script's explicit npm/Node commands inside its seven registered marked phase +bodies, without executing shell code or inferring arbitrary functions, nested +scripts, npm hooks, branches or here-document contents. Each covered suite names +the `focused.` that owns it. The +current gate names four of the sixteen Core component suites: layout primitives, +page layout, DataGrid actions/sizing, and Mail components. The other twelve stay +explicitly excluded from that gate's component batch. `ui` runs the shared +sixteen-suite batch once; `quick` does not compile component tests. + +Only Core's exact known component aliases receive that shared-batch treatment. +A different repository using the same filename, an unknown alias, or a compound +command containing the runner is not silently credited or launched. Ordinary +scoped source discovery accepts bounded direct `node`/`node --test` commands +targeting regular package-owned `.mjs` files in `scripts` or `tests`; shell +chains, extra flags, missing targets and symlinked targets are not rewritten. +An explicit invocation already present in the canonical full gate remains part +of that gate, even when the narrower discovery profile does not support it. + +Coverage rows retain a command hash and, for supported commands, redacted argv. +Unsupported shell bodies are not copied into the inventory. This is display +hygiene, not permission to put secrets into project commands. Coverage is +attached to check plans/receipts; it never turns an excluded suite into a pass. + +## Portable-project validation + +The runtime validates custom project metadata against the published +[`project.schema.json`](../../tools/devkit/project.schema.json) using a small +dependency-free validator. All checks and profiles are validated before +selection, including checks the chosen profile does not execute. Unknown nested +fields such as `resource` or `timout_seconds`, malformed types, oversized values, +duplicate references, missing dependencies, unknown profiles and dependency +cycles fail with controlled errors. Repository aliases and resolved paths must +be unambiguous and confined to the workspace. Check `cwd` defaults to `.` when +omitted; configured paths cannot escape the workspace. + +Package JSON is bounded to 1 MiB and 512 scripts; duplicate JSON keys, excessive +nesting and malformed script objects fail before planning. Command strings are +bounded to 8,192 characters. This validates configuration, not the safety of its +code: run only project manifests and test scripts you trust. + +Checks may declare repository-scoped `inputs`, true data `deps`, order-only +`after`, and `reuse: "never"` for setup/output-producing work. Unknown repositories, +empty/duplicate scopes, overlapping edge types and cycles fail before execution. +Missing inputs deliberately fall back to all registered repositories. See +[checkpoint and input contracts](DEVKIT.md#canonical-phases-and-narrower-inputs). diff --git a/docs/operations/DEVKIT_EVIDENCE.md b/docs/operations/DEVKIT_EVIDENCE.md new file mode 100755 index 0000000..46857aa --- /dev/null +++ b/docs/operations/DEVKIT_EVIDENCE.md @@ -0,0 +1,171 @@ +# Devkit review bundles and issue evidence + +Gitea remains the canonical backlog and review state log. These commands assemble +guidance and append evidence; they do not create a second progress tracker, close +issues, rewrite issue bodies, tick checkboxes, or certify a module review. + +## Prepare a module review + +```sh +./devkit review campaign +./devkit review campaigns --json --output /tmp/campaign-review.json +./devkit review campaign --evidence RUN_ID +``` + +Repository names, repository aliases and scope IDs from the +[issue discovery inventory](../project/ui-review-issue-inventory.json) are accepted. +`review bundle campaign` is an equivalent spelling. A bundle contains the current +source inventory and fingerprint, canonical module/central issue links, the +Core principle revision and content hash, applicable **planned** check stages, +and a manual checklist. No checks or application servers are started. The +optional output file is a local snapshot, not an authoritative state record. + +The filename inventory is a starting point, not proof that all runtime surfaces +were found. It does not import application manifests or optional modules. Review +routes, panes, dialogs, settings, widgets, public forms, contributed/headless +interfaces, missing-module behavior, permissions, English/German and narrow/wide +layouts manually. Missing source, no UI files or no planned checks is never an +automatic pass or N/A. Record exceptions and remaining work in the module issue. + +Apply [UI-01 and UI-02, and the other Core principles](../../../govoplan-core/docs/UI_DESIGN_PRINCIPLES.md): +books belong beside meaningful visible text; normal screens display compact +data, with intentional scoped dialog editing. A bulk editing mode needs a +documented reason. Record new principles and their back-propagation to previously +reviewed modules through the [central review program](../project/UI_REVIEW_PROGRAM.md). + +An explicit portable `--project project.json` does not inherit GovOPlaN issue +links or principles. Its optional configuration is: + +```json +{ + "review": { + "issue_inventory": "meta/docs/review-issues.json", + "principles": "core/docs/UI_DESIGN_PRINCIPLES.md" + } +} +``` + +Paths are relative to `--workspace-root` and cannot escape it or traverse +symlinks. The inventory uses schema version 1, `issues[]` with `scope_id`, +`repository`, `number`, `url`, and optional `name`/`kind`; an optional `epic` +contains `repository`, `number`, `url`. Snapshot issue-state fields are not +reported as current state. Custom checks come from the project's selected +profile (`--profile ui` by default). + +## Preview and append evidence + +```sh +./devkit issues note --root govoplan-campaign --issue 103 \ + --evidence RUN_ID --summary 'Targeted checks passed.' \ + --next 'Manual module review and remaining fixes are still required.' --json + +./devkit issues note --root govoplan-campaign --issue 103 \ + --evidence RUN_ID --summary 'Targeted checks passed.' \ + --next 'Manual module review and remaining fixes are still required.' \ + --env-file /private/gitea.env --apply +``` + +`--root` is resolved relative to the selected **workspace root**, not the shell's +current directory. In the normal siblings workspace, use `--root govoplan-campaign` +or an absolute checkout path; do not use a path escaping the workspace. + +The default is a completely offline dry run; it does not load credentials or +contact Gitea. `--apply` explicitly authorizes comment creation. Tokens are read +from `GITEA_TOKEN` or the explicit `--env-file` (environment wins); there is no +implicit `.env` search. The credential file is parsed, never sourced, and is not +printed or uploaded. Git's selected remote (default `origin`) determines the +exact target: ambient `GITEA_OWNER`, `GITEA_REPO` and `GITEA_URL` cannot retarget +the request. Credential-bearing HTTP Git remote URLs are rejected. + +Use repeatable `--summary`/`--next`, `--body-file` for Markdown, or `--note-file` +with structured JSON `{ "summary": ["..."], "next": ["..."], "body": "..." }`. +Do not put secrets in notes, paths, check arguments or logs. Display redaction is +best-effort protection, not permission to include confidential data. + +For a bounded multi-issue operation, replace `--root`/`--issue` with +`--target-plan targets.json`: + +```json +{ + "schema_version": 1, + "targets": [ + { + "root": "govoplan-campaign", + "issue": 103, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/103" + } + ] +} +``` + +Targets must be unique, inside the workspace and bound exactly to their Git +remote/issue URLs, all on one exact Gitea base URL. Optional `issue_id` binds +Gitea's immutable numeric ID as well. Every target, complete comment history, +marker collision and local journal binding is checked before the first POST. +Comments are then posted serially and read back against the exact issue. A +closed issue stays closed; existing bodies, comments and checklists are untouched. + +Each note has a stable hidden marker binding its issue, `--key` purpose +(`verification` by default), and evidence run ID. Identical already-published +notes are read back instead of reposted; changed content with the same marker +is a collision, not an update. Use a distinct intentional `--key` for a separate +follow-up. Without a receipt, the note's structured content determines identity. + +A lost POST response is **not** retried automatically. The publisher scans all +comment pages and reads back any matching comment. If it cannot confirm the +result, it records `uncertain`, stops later posts, and exits nonzero. The next +invocation reconciles again without replaying the POST. Only after inspecting +the issue and local result, use `--apply --retry-uncertain` to permit one new +attempt when the marker is still absent. Local journals and OS-released locks +live in the workspace-scoped devkit state directory; do not delete them to bypass +uncertainty. These are retry receipts, not issue state. Independent machines or +different state directories cannot share a lock: coordinate publishers, because +Gitea comment creation does not provide an atomic idempotency key. + +## What a receipt does and does not prove + +`--evidence RUN_ID` reads the workspace-scoped runner receipt and checks local +integrity. `--evidence /path/to/receipt.json` accepts bounded validated external +receipt metadata, explicitly labeled `external-unverified`. A self-consistent +digest is not a signature or independent verification. Foreign-workspace, +malformed and inconsistent successful receipts are rejected. Receipt-provided +commands are never run; log files are not opened or uploaded, only referenced. +Successful receipts must explicitly record a verified source snapshot; an +external file's flag is still a reported claim, not an independent attestation. +An aggregate containing a skipped stage cannot claim success. New versioned +receipts additionally require independent verified checkpoints for passed phases. +Failed/interrupted/stale aggregates remain labeled as such even when they contain +reusable successful phases; reuse creates a new run and preserves the original. +Stage coverage limitations are validated and retained in the note, including +checks omitted by scoped profiles. Compact review output shows the first eight +limitations and points to the complete list in its JSON bundle. A passing stage +never implies that omitted compiler, browser, permutation or manual checks ran. + +Documentation audit limitations are saved with their stage receipts, so later +review bundles and issue notes retain the same scope as the original command. +Managed inventory collectors use the explicit selected workspace throughout; +an incomplete checkout never borrows sources or missing module imports from a +fuller default workspace. Missing required Core/parser dependencies fail the +audit. Legacy direct inventory commands without `--workspace-root` retain their +discovery behavior; use the explicit option for reproducible evidence. + +The note distinguishes `matches-current`, `historical-source-differs`, +`different-project-not-compared` and unavailable comparisons. Matching source +is a comparison at bundle/note preparation time, not a live test run or proof +that a person completed review. If the source or environment has changed, run +the appropriate checks again and keep historical evidence labeled honestly. + +Versioned repository-input receipts compare the recorded repository scope, not a +legacy whole-workspace hash. Partial scopes remain explicitly labeled in evidence +notes. This comparison reads source only; receipt commands are never executed. +Legacy receipts retain their original comparison format. A matching source scope +does not prove that ignored build outputs, the environment or live services match. + +Fixtures cover dry-run isolation, exact target bindings, capped/repeated +pagination, collisions, append-only idempotency, uncertain POST reconciliation, +secret-safe errors, receipt validation, portable projects and optional-module +review boundaries: + +```sh +python3 -m pytest -q tests/test_devkit_issues.py tests/test_devkit_review.py +``` diff --git a/docs/operations/DEVKIT_MAINTENANCE.md b/docs/operations/DEVKIT_MAINTENANCE.md new file mode 100755 index 0000000..207c310 --- /dev/null +++ b/docs/operations/DEVKIT_MAINTENANCE.md @@ -0,0 +1,97 @@ +# Selected-path Git maintenance + +The optional `git` namespace of `tools/devkit/devkit.py` is a deliberately +narrow maintenance workflow. It is not a replacement for normal Git, the +release executor, required human review, or Gitea's canonical issue state. + +From the Meta repository, preview and then save a plan for explicit files: + +```sh +python3 tools/devkit/devkit.py git plan --repo core --path webui/src/example.ts --message "Fix the reviewed example" +python3 tools/devkit/devkit.py git plan --repo core --path webui/src/example.ts --message "Fix the reviewed example" --apply +``` + +Use the saved `git-…` plan ID for the remaining commands. Each mutation needs +its own `--apply`; an unqualified command only previews the operation: + +```sh +python3 tools/devkit/devkit.py git commit git-PLAN_ID +python3 tools/devkit/devkit.py git commit git-PLAN_ID --apply +python3 tools/devkit/devkit.py git push git-PLAN_ID +python3 tools/devkit/devkit.py git push git-PLAN_ID --apply +python3 tools/devkit/devkit.py git status git-PLAN_ID +``` + +The immutable local plan binds the registered repository, HEAD, branch, origin +fetch/push URL hashes, Git configuration, complete index identity, explicit +selected file paths, working-tree hashes, expected Git blobs, and commit message. +Raw remote URLs and file contents are not stored. A changed input blocks the +operation; inspect it and create another plan instead of weakening the check. +Receipts are private local records with integrity checks, not signed approval +or a security attestation. + +Commit captures each selected file once, verifies the captured bytes and Git +blob against the plan, and builds an isolated index/tree from the frozen HEAD +plus only those blobs. Git's normal identity rules create the commit object; +an atomic branch compare-and-swap publishes it only if the planned parent is +still current. Active hooks/signing remain refused rather than bypassed. +An editor racing with this operation cannot substitute newer working-tree +bytes: those edits remain uncommitted, and the helper never overwrites files. + +The real index is protected by Git's standard index lock. Only selected entries +are updated from a prepared private copy; unrelated staging and index flags are +preserved. If the index changes independently, its new data is not overwritten +and the receipt requires reconciliation. The resulting parent, message, +changed paths, blobs/modes and unrelated index entries are verified. +Git refs and index files are separately atomic, not one filesystem transaction. +Independent writers that ignore the standard index lock can still change refs +during publication. The helper rechecks the exact recorded candidate before +index recovery and before recording success; it refuses an uncertain result +instead of adopting a newer HEAD or claiming that competing work was its own. +Directories, noncanonical paths, implicit globs, staging-all, amend, +force-push and extra-tag publication are not supported. A selected file with +different staged and working-tree changes is refused; decide explicitly which +version to commit using normal Git first. + +Push requires the recorded commit to remain current, an unchanged origin and +one standard-transport push destination. It requests a normal, non-forced push +of that commit to the recorded branch and checks the remote branch afterward. +The push preview does not contact a remote. A normal push may run the remote's +usual CI or server-side hooks; this is an external effect of the separately +authorized push, not of planning or committing. + +Local active hooks (including index-change hooks), filter/encoding attributes, +signing, external fsmonitor/SSH configuration, Git environment overrides, +in-progress merge/rebase/cherry-pick operations, submodules, symlinks and +assume-unchanged/skip-worktree entries, split indexes and replacement/graft +history are deliberately +unsupported. The helper +refuses these cases instead of disabling hooks, signatures or filters. An +unused globally installed filter definition alone does not block maintenance; +active attributes are checked across tracked files as well as selected files. +Custom remote receive-pack/upload-pack/helper commands, recursive submodule +pushes and partial-clone lazy fetch are also refused. Of inherited `GIT_*` +variables, only `GIT_OPTIONAL_LOCKS`, `GIT_TERMINAL_PROMPT` and `GIT_PAGER` are +allowed (every command explicitly passes `--no-pager`, so the pager is inert); +namespace, identity, alternate-index/object-directory and unknown overrides +are not silently removed. Git subprocesses have bounded input/output and +deadlines; cancellation terminates their owned process group, including +transport helpers. Network failures can still leave a remote effect uncertain, +which is why receipts require explicit reconciliation rather than blind retry. + +An interruption or uncertain failure is recorded before any retry. Use +`git reconcile git-PLAN_ID` to preview, then add `--apply` to verify an +already-existing result. Reconciliation never makes a commit or push. It may +finish the selected real-index update after an interrupted commit publication, +but only when the original index fingerprint still matches; independent staged +work is preserved and requires normal Git resolution. For an +uncertain push it may read the frozen remote, but only with `--apply`. If no +planned commit is current, the helper reports that state and requires a fresh +plan after inspection. A forcibly killed process may leave its private scratch +index or an owned Git index lock; the helper never guesses that an existing +lock is safe to delete. Blob/commit preparation can leave unreferenced Git +objects for ordinary Git garbage collection. It never automatically resets, +restores, deletes, or rolls back user work. + +The maintenance tests use disposable local repositories and local bare remotes. +They never commit or push the user's workspace repositories. diff --git a/docs/operations/DEVKIT_RELEASE.md b/docs/operations/DEVKIT_RELEASE.md new file mode 100755 index 0000000..df86c9b --- /dev/null +++ b/docs/operations/DEVKIT_RELEASE.md @@ -0,0 +1,121 @@ +# Headless durable releases + +The devkit `release` commands use the **same release application, request +validation, run store and bounded executors** as the +[release console](RELEASE_CONSOLE.md). They invoke its ASGI application inside +the current process, with an ephemeral internal token. No listening socket, +background server, browser session or externally supplied console URL is used. +The development environment's FastAPI/HTTPX dependencies load only when a +release command runs; ordinary devkit help does not import them. + +This is a GovOPlaN-specific provider. Repository and origin authority still comes +from the release service's registered catalog. `--project` is explicitly rejected +for release commands; a portable project configuration must not silently override +that authority. A different workspace path does not authorize arbitrary release +repositories, remotes, commands, signing policies or source bindings. + +The default compact output includes the overall status, selected repository +states and versions, source-preflight readiness, bounded gate findings and the +recommended next action. Run inspection also shows step-state counts and the +first outstanding steps. These are projections of the service response, not new +readiness checks. Use `--json` for the complete existing plan or receipt; compact +output deliberately omits executor commands, arguments and source bindings. + +## Commands and boundaries + +| Command | Purpose | Effect boundary | +| --- | --- | --- | +| `release status` | Current release dashboard | Offline by default; explicit remote/catalog flags enable their checks | +| `release plan` | Selective repository/version plan | Inspection only; does not create a run | +| `release list` | Bounded run history, with `--limit` and `--cursor` | Existing workspace-scoped store | +| `release show RUN_ID` | Exact verified run, steps, receipts and required confirmations | No release execution | +| `release create` | Freeze a selected repository/version plan | Preview by default; `--apply` persists a run | +| `release preview RUN_ID STEP_ID` | Frozen step and its current constraints | No step claim; catalog publication additionally uses the existing validated-candidate preview | +| `release execute RUN_ID STEP_ID` | One available durable step | Preview by default; `--apply`, explicit request ID and the step's exact confirmation are required | +| `release resume RUN_ID` | Mark persisted running attempts interrupted | Requires `--apply`; never assumes an effect succeeded | +| `release retry RUN_ID STEP_ID` | Prepare an eligible failed/read-only-interrupted step | Requires `--apply`; does not execute it | +| `release reconcile RUN_ID STEP_ID` | Record a proven uncertain-write outcome | Requires `--apply`, `--confirm RECONCILE` and an explicit outcome | + +Create and transition commands require caller-chosen `--request-id` values. +Retain the same ID for an uncertain replay of the **same** command; changing +inputs under an existing ID fails closed. No command automatically retries a +mutation. These commands perform one explicit transition, not an implicit +"release everything" loop. + +Read commands do not commit, tag, push, publish or apply database migrations. +The existing private run store can initialize its lock directory or upgrade a +legacy record while inspecting it; this does not advance release steps. +`--include-migrations` requests the existing audits, never migration application. +`--online`, `--remote-tags` and `--public-catalog` are explicit network-check +choices. A generic step preview describes frozen intent; it is not a claim that +live preflight, remote identity or release acceptance has passed. + +## Example: review, freeze and execute one step + +Run the devkit through the workspace `./devkit` entrypoint. These examples put +global options before `release`: + +```sh +./devkit --workspace-root /path/to/workspace --format json release plan \ + --repo-version govoplan-files=0.1.9 + +./devkit --workspace-root /path/to/workspace release create \ + --repo-version govoplan-files=0.1.9 --request-id files-release-create-0001 + +./devkit --workspace-root /path/to/workspace release create \ + --repo-version govoplan-files=0.1.9 --request-id files-release-create-0001 --apply + +./devkit --workspace-root /path/to/workspace release show RUN_ID +./devkit --workspace-root /path/to/workspace release preview RUN_ID STEP_ID +./devkit --workspace-root /path/to/workspace release execute RUN_ID STEP_ID \ + --request-id files-release-step-0001 --confirm REQUIRED_CONFIRMATION --apply +``` + +`RUN_ID`, `STEP_ID` and `REQUIRED_CONFIRMATION` stand for the actual values +returned by the frozen run. For read-only preflight/alignment/install-verification +steps, omit `--confirm` when the service reports an empty confirmation. Metadata, +commit, release-lock, source-tag, source-push, candidate-generation and catalog +publication steps keep the existing `UPDATE`, `COMMIT`, `LOCK`, `TAG`, `PUBLISH`, +`GENERATE` and `PUSH` confirmations respectively. Prerequisite ordering is enforced +by the same store; a later step cannot be forced through this adapter. + +Version selection supports repeated `--repo-version REPO=VERSION`, or repeated +`--repo REPO` with `--target-version VERSION`. Every created run needs explicit +versions for all selected repositories. Conflicting version assignments are +rejected. Planning is selective; repositories are never silently selected merely +because their worktrees are dirty. + +## Candidate publication and recovery + +Use the frozen run's `catalog:selective-generator` and +`catalog:validate-sign-publish` steps, not the disabled legacy mutation endpoints. +Generation accepts repeated `--signing-key KEY_ID=PRIVATE_KEY_FILE`; these are +operator-owned key-file references, never inline key material. Signing arguments +are not emitted in devkit JSON or persisted by the adapter. Publication consumes +the candidate receipt created by that run, not an arbitrary candidate directory. +The existing source/runtime trust, immutable tag/remote identity, private +candidate checks and exact commit-delta checks remain authoritative. + +After an interrupted write, inspect the run and the actual local/remote effect. +Use `resume --apply` if an attempt was left running. Then use `reconcile` with +`effect_absent`, `effect_succeeded` or `unresolved`, `--confirm RECONCILE`, a new +reconciliation request ID and `--apply`. Successful reconciliation still performs +the service's independent receipt checks. An unresolved effect must not be +retried or papered over by generating a new request ID. A failed/read-only +attempt may use `retry --apply` and a new explicit execute attempt only when +the existing lifecycle makes that safe. + +## State and limitations + +Every successful response exposes `state_location` and `candidate_location`. +Without `--state-dir`, the adapter uses the console's existing default private, +workspace-fingerprinted state directory. With `--state-dir PATH`, it uses +`PATH/release-console/workspace-/release-runs`. Workspaces cannot +read or resume one another's runs. Existing ownership, symlink, permission, +retention and record-integrity checks are unchanged. + +Release preparation can commit only the recognized, receipt-bound metadata +changes its executors produced. This command does **not** stage arbitrary source +changes, use `git add -A`, force-push, retarget tags or enable the disabled generic +prepare/sync/push endpoints. Normal selected-path maintenance requires its own +explicit reviewed-change workflow; it is not silently folded into release. diff --git a/docs/operations/RELEASE_CONSOLE.md b/docs/operations/RELEASE_CONSOLE.md index dbc167b..2fed509 100644 --- a/docs/operations/RELEASE_CONSOLE.md +++ b/docs/operations/RELEASE_CONSOLE.md @@ -354,8 +354,17 @@ such as `0.2.0` or `0.2.0-alpha1`, but requires the first three version numbers to move forward. Plain repository pushes are separate from catalog publication. `Preview Push` -shows the selected repository push commands. `Push Selected` requires `PUSH` in -the repository push confirmation field. +shows the selected repository push commands, but generic push, sync and prepare +mutation endpoints are disabled: they require a separate durable, receipt-bound +maintenance workflow and cannot be enabled by typing a confirmation. Source +release branch/tag publication uses the durable release-run steps described +above; do not route ordinary dirty worktrees through the legacy all-repository +stage/commit/tag helper. + +The [headless devkit release commands](DEVKIT_RELEASE.md) invoke this same +application in-process without starting a server. They expose selective planning, +bounded status/history, create/show/preview/execute and explicit recovery while +retaining the same request IDs, confirmations, source bindings and receipts. The source release panel retains `Preview Tag + Publish` as a non-mutating inspection. Its legacy `Create Tags` and `Publish Tags` controls stay visible diff --git a/docs/project/UI_REVIEW_PROGRAM.md b/docs/project/UI_REVIEW_PROGRAM.md new file mode 100755 index 0000000..c397064 --- /dev/null +++ b/docs/project/UI_REVIEW_PROGRAM.md @@ -0,0 +1,164 @@ +# Product-wide UI review program + +The [central Gitea epic](https://git.add-ideas.de/GovOPlaN/govoplan/issues/56) +coordinates the review; its linked module issues are the canonical backlog, +progress and evidence log. The [issue inventory](ui-review-issue-inventory.json) +is a discovery/link snapshot, **not a second progress tracker**. All reviews were +initialized pending. Implementing a shared component or moving a help icon does +not by itself complete a module review. + +The visual and interaction rules live in Core's +[UI design principles](https://git.add-ideas.de/GovOPlaN/govoplan-core/src/branch/main/docs/UI_DESIGN_PRINCIPLES.md). +This document defines how to apply and verify those principles across repositories; +it does not fork their definitions. The usual [Gitea issue contract](GITEA_ISSUES.md) +still applies. At the user's request, this cross-product program is coordinated in +Meta; shared primitives and their implementation remain owned by Core. + +## Scope and initial inventory + +The September 2026 source/catalog baseline contains **77 review tracks**: + +- **73 implemented scopes:** Core/shared shell and 72 manifest-backed modules. +- **4 registered placeholders:** Appointments, Ledger, XÖV and XTA/OSCI. They have + README-only repositories at initialization. Their separate readiness tracks + remain pending for future interfaces; they are not counted as implemented or + reviewed modules. + +Meta and the public website are not business-module review scopes. Module IDs +come from actual manifests, not repository-name guesses: for example, +`govoplan-campaign` owns `campaigns` and `govoplan-dist-lists` owns `dist_lists`. +The inventory is derived from `repositories.json`, trusted module manifests and +the owning repository's source files. Changes to the catalog must be reconciled +with the central epic rather than silently dropping a module. + +A module with no standalone WebUI is still in scope. Review its configuration +and administrator experience, contributed operator/public surfaces, widgets, +documentation, permissions, errors and interactions exposed through other +modules. A missing standalone page is not evidence of conformance. A reviewer +may mark a particular principle not applicable only with a concrete explanation +and source/runtime evidence. A placeholder likewise needs an explicit future-UI +gate, not a fabricated page inventory or an automatic green review. + +## Work order and ownership + +Prioritize user-visible defects, usability and consistent interactions before +broader features. Start with Core/shared-shell contracts and Campaign's complete +workflow, then Files, Mail, Templates, Notifications, Docs/Help, Dashboard, Quick +Access and Views. The remaining queue is ordered by observed defects, shared +dependencies and risk. This is a starting order, not a claim that those reviews +have started or finished; the central issue records the current focus. + +For Campaign, use a compact, read-only campaign settings overview with explicit, +scoped edit dialogs. Large recipient or attachment grids may use the UI-02 bulk +editing exception: clearly entered edit mode, explicit Save and Cancel, dirty +navigation protection, and no silent loss or accidental save. This direction +must be implemented and verified in the Campaign review, not marked complete by +the program bootstrap. + +Fix a repeated problem in the owning shared primitive or semantic contract first, +then migrate consumers and verify representative actual-module fixtures. Keep +module business behavior in its own repository and preserve optional module +boundaries. Do not replace concrete user evidence with a generic fixture alone. +Parallel reviewers may own different modules, but they must coordinate shared +Core files and browser fixtures instead of racing the same resources. + +## Per-module review workflow + +1. **Inventory.** Verify and extend the issue's source-derived seed: navigation, + pages, nested routes, dialogs, labels/forms, tables/trees, admin/system/tenant/ + user settings, public/operator interfaces, widgets and optional-module + contributions. Follow actual module entrypoints and runtime contributions; + filename heuristics and manifest routes are a starting point, not an exhaustive + runtime audit. Note roles, permission boundaries and installed optional modules. +2. **Observe.** Exercise realistic narrow and wide viewports, German and English, + keyboard navigation and focus, empty/loading/error/success states, clean/dirty + edit modes, reload, navigation away and return, and restoration of personal + preferences. Include realistic row counts, long labels, horizontal overflow, + pagination and two-way column resizing with fixed columns between text fields. +3. **Record.** Keep a findings/TODO/done ledger in the module issue. Every finding + names the surface, reproduction, expected principle, impact, owner and linked + implementation/evidence. A source inventory, planned fix or green generic + test is not a completed finding. State product decisions and manual checks + explicitly with enough context to resolve them. +4. **Implement.** Prefer reusable shared primitives and action/page contracts. + Keep display mode readable and editing intentional. Preserve authorization, + server validation, save/cancel behavior and data integrity. Never send mail, + delete records, change live configuration or trigger other irreversible effects + merely to obtain UI evidence; use authorized fixtures or a safe test context. +5. **Verify.** Run proportionate unit, structure and actual-module browser tests. + Record commands, results, source revision and safe runtime evidence. Update the + owning module's manifest-driven EN/DE documentation for behavior changes; run + `tools/checks/check-manifest-shapes.py` and relevant cross-module checks. +6. **Conclude honestly.** Complete the principle matrix, evidence and manual + checks. Link unresolved follow-ups and blockers; do not close a supposedly + complete review while required work is still pending. Closing an issue requires + explicit reviewed scope and the applicable design-principle revision. + +## Principle ledger and back-propagation + +Use the stable IDs from Core in each issue. The initial revision covers UI-01 +heading/label help placement; UI-02 display-first/scoped editing; UI-03 actions; +UI-04 table/card geometry; UI-05 loading/error/progress; UI-06 tree interaction; +UI-07 accessibility and German; UI-08 data integrity; and UI-09 propagation of +revised principles. Core remains the authoritative definition. + +Each module maintains this matrix, initially entirely pending: + +| Principle/revision | Applicable surfaces / justified N/A | Applied / remaining work | Evidence | Exception / owner / follow-up | +| --- | --- | --- | --- | --- | +| UI-01 … UI-09 | Pending inventory | Pending review | Not yet recorded | None approved | + +Whenever a principle is added or changed: + +1. Update Core's rule with its rationale, stable ID and revision/change reference. +2. List **all already-reviewed modules**, not just the current implementation's + consumers. Check whether the changed rule applies to each. +3. Record unchanged conformance with evidence, or reopen the review / create a + linked follow-up with owner, exact affected surfaces and required checks. +4. Update the central epic's propagation ledger. Previously reviewed modules with + outstanding applicable work are **follow-up required**, not silently green. +5. Resolve the propagation sweep only after every affected module has current + evidence or an explicit, owned and justified exception. Exceptions do not + silently change the shared rule. + +Suggested central ledger: + +| Principle change | Already-reviewed modules checked | Conformant evidence | Reopened / follow-up required | Exceptions / owner | Sweep state | +| --- | --- | --- | --- | --- | --- | +| Initial UI-01–UI-09 baseline | None reviewed at initialization | None | All module reviews pending | None approved | Pending | + +## Safe inventory/bootstrap automation + +`tools/gitea/gitea-ui-review-program.py` derives the current scopes from the +catalog and source manifests, inventories routes and UI source entrypoints, +then deduplicates issues by a stable hidden marker and normalized exact title. +It includes closed issues in duplicate checks and never reopens, closes or +overwrites existing module issues. Ambiguous or unmanaged duplicates stop the +operation for review. Existing issue bodies, labels and review progress are +preserved. A failed POST is not automatically retried; a subsequent run checks +the marker again before deciding whether another create is needed. + +Dry-run is the default. An authenticated dry-run performs only Gitea reads: + +```sh +.venv/bin/python tools/gitea/gitea-ui-review-program.py \ + --env-file /home/zemion/.config/gitea/gitea.env --epic 56 +``` + +Creating missing review issues requires explicit `--apply`. Initial population +of the central epic's module-link block additionally requires +`--initialize-links`; it is accepted only for the untouched bootstrap placeholder. +Later runs must not rewrite checkboxes or human-maintained progress. Existing +complete links are verified read-only. The token is loaded through the shared +Gitea helpers and is never emitted in reports. A scoped `--ipv4` option works +around host-specific IPv6 connectivity while retaining HTTPS certificate and +hostname verification. + +The tool emits a JSON result to stdout with source counts and issue links. The +checked-in inventory is a reviewed initialization snapshot of that result; +refreshing links does not authorize replacing live review status with the +snapshot. Test the automation offline with: + +```sh +.venv/bin/python -m pytest tests/test_ui_review_program.py -q +``` diff --git a/docs/project/ui-review-issue-inventory.json b/docs/project/ui-review-issue-inventory.json new file mode 100755 index 0000000..6b49d5a --- /dev/null +++ b/docs/project/ui-review-issue-inventory.json @@ -0,0 +1,866 @@ +{ + "schema_version": 1, + "snapshot_purpose": "Issue discovery links; live Gitea issues own review state and evidence.", + "epic": { + "repository": "govoplan", + "number": 56, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan/issues/56" + }, + "scope_count": 77, + "manifest_modules": 72, + "implemented_scopes": 73, + "catalogued_placeholders": 4, + "created": 77, + "missing": 0, + "issues": [ + { + "scope_id": "core", + "name": "Core / shared shell", + "repository": "govoplan-core", + "kind": "core", + "ui_source_count": 111, + "number": 301, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/301", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "access", + "name": "Access", + "repository": "govoplan-access", + "kind": "manifest", + "ui_source_count": 18, + "number": 23, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-access/issues/23", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "addresses", + "name": "Addresses", + "repository": "govoplan-addresses", + "kind": "manifest", + "ui_source_count": 1, + "number": 26, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/26", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "admin", + "name": "Admin", + "repository": "govoplan-admin", + "kind": "manifest", + "ui_source_count": 9, + "number": 11, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-admin/issues/11", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "approvals", + "name": "Approvals", + "repository": "govoplan-approvals", + "kind": "manifest", + "ui_source_count": 4, + "number": 5, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-approvals/issues/5", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "assets", + "name": "Assets", + "repository": "govoplan-assets", + "kind": "manifest", + "ui_source_count": 0, + "number": 1, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-assets/issues/1", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "audit", + "name": "Audit", + "repository": "govoplan-audit", + "kind": "manifest", + "ui_source_count": 1, + "number": 10, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-audit/issues/10", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "booking", + "name": "Booking", + "repository": "govoplan-booking", + "kind": "manifest", + "ui_source_count": 0, + "number": 1, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-booking/issues/1", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "calendar", + "name": "Calendar", + "repository": "govoplan-calendar", + "kind": "manifest", + "ui_source_count": 10, + "number": 26, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-calendar/issues/26", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "campaigns", + "name": "Campaigns", + "repository": "govoplan-campaign", + "kind": "manifest", + "ui_source_count": 60, + "number": 103, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-campaign/issues/103", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "cases", + "name": "Cases", + "repository": "govoplan-cases", + "kind": "manifest", + "ui_source_count": 4, + "number": 8, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-cases/issues/8", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "certificates", + "name": "Certificates", + "repository": "govoplan-certificates", + "kind": "manifest", + "ui_source_count": 0, + "number": 1, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-certificates/issues/1", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "committee", + "name": "Committee", + "repository": "govoplan-committee", + "kind": "manifest", + "ui_source_count": 3, + "number": 5, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-committee/issues/5", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "connectors", + "name": "Connectors", + "repository": "govoplan-connectors", + "kind": "manifest", + "ui_source_count": 3, + "number": 20, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-connectors/issues/20", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "consultation", + "name": "Consultation", + "repository": "govoplan-consultation", + "kind": "manifest", + "ui_source_count": 0, + "number": 1, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-consultation/issues/1", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "contracts", + "name": "Contracts", + "repository": "govoplan-contracts", + "kind": "manifest", + "ui_source_count": 0, + "number": 1, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-contracts/issues/1", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "dashboard", + "name": "Dashboard", + "repository": "govoplan-dashboard", + "kind": "manifest", + "ui_source_count": 5, + "number": 6, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-dashboard/issues/6", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "dataflow", + "name": "Dataflow", + "repository": "govoplan-dataflow", + "kind": "manifest", + "ui_source_count": 5, + "number": 24, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-dataflow/issues/24", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "datasources", + "name": "Datasources", + "repository": "govoplan-datasources", + "kind": "manifest", + "ui_source_count": 1, + "number": 10, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-datasources/issues/10", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "decisions", + "name": "Decisions", + "repository": "govoplan-decisions", + "kind": "manifest", + "ui_source_count": 0, + "number": 2, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-decisions/issues/2", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "dist_lists", + "name": "Distribution Lists", + "repository": "govoplan-dist-lists", + "kind": "manifest", + "ui_source_count": 2, + "number": 10, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-dist-lists/issues/10", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "dms", + "name": "DMS", + "repository": "govoplan-dms", + "kind": "manifest", + "ui_source_count": 0, + "number": 3, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-dms/issues/3", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "docs", + "name": "Docs", + "repository": "govoplan-docs", + "kind": "manifest", + "ui_source_count": 2, + "number": 23, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-docs/issues/23", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "encryption", + "name": "Encryption", + "repository": "govoplan-encryption", + "kind": "manifest", + "ui_source_count": 1, + "number": 7, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-encryption/issues/7", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "erp", + "name": "ERP", + "repository": "govoplan-erp", + "kind": "manifest", + "ui_source_count": 0, + "number": 2, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-erp/issues/2", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "evaluation", + "name": "Evaluation", + "repository": "govoplan-evaluation", + "kind": "manifest", + "ui_source_count": 0, + "number": 1, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-evaluation/issues/1", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "facilities", + "name": "Facilities", + "repository": "govoplan-facilities", + "kind": "manifest", + "ui_source_count": 0, + "number": 1, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-facilities/issues/1", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "files", + "name": "Files", + "repository": "govoplan-files", + "kind": "manifest", + "ui_source_count": 8, + "number": 48, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-files/issues/48", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "fit_connect", + "name": "FIT-Connect", + "repository": "govoplan-fit-connect", + "kind": "manifest", + "ui_source_count": 0, + "number": 2, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-fit-connect/issues/2", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "forms", + "name": "Forms", + "repository": "govoplan-forms", + "kind": "manifest", + "ui_source_count": 2, + "number": 7, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-forms/issues/7", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "forms_runtime", + "name": "Forms Runtime", + "repository": "govoplan-forms-runtime", + "kind": "manifest", + "ui_source_count": 6, + "number": 7, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-forms-runtime/issues/7", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "grants", + "name": "Grants", + "repository": "govoplan-grants", + "kind": "manifest", + "ui_source_count": 0, + "number": 1, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-grants/issues/1", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "helpdesk", + "name": "Helpdesk", + "repository": "govoplan-helpdesk", + "kind": "manifest", + "ui_source_count": 1, + "number": 1, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-helpdesk/issues/1", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "identity", + "name": "Identity", + "repository": "govoplan-identity", + "kind": "manifest", + "ui_source_count": 1, + "number": 5, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-identity/issues/5", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "identity_trust", + "name": "Identity Trust", + "repository": "govoplan-identity-trust", + "kind": "manifest", + "ui_source_count": 1, + "number": 4, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-identity-trust/issues/4", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "idm", + "name": "IDM", + "repository": "govoplan-idm", + "kind": "manifest", + "ui_source_count": 3, + "number": 14, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-idm/issues/14", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "inspections", + "name": "Inspections", + "repository": "govoplan-inspections", + "kind": "manifest", + "ui_source_count": 0, + "number": 1, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-inspections/issues/1", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "learning", + "name": "Learning", + "repository": "govoplan-learning", + "kind": "manifest", + "ui_source_count": 0, + "number": 1, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-learning/issues/1", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "mail", + "name": "Mail", + "repository": "govoplan-mail", + "kind": "manifest", + "ui_source_count": 6, + "number": 25, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-mail/issues/25", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "mandates", + "name": "Mandates", + "repository": "govoplan-mandates", + "kind": "manifest", + "ui_source_count": 0, + "number": 2, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-mandates/issues/2", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "notifications", + "name": "Notifications", + "repository": "govoplan-notifications", + "kind": "manifest", + "ui_source_count": 3, + "number": 7, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-notifications/issues/7", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "ops", + "name": "Ops", + "repository": "govoplan-ops", + "kind": "manifest", + "ui_source_count": 2, + "number": 5, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-ops/issues/5", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "organizations", + "name": "Organizations", + "repository": "govoplan-organizations", + "kind": "manifest", + "ui_source_count": 4, + "number": 9, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-organizations/issues/9", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "parties", + "name": "Parties", + "repository": "govoplan-parties", + "kind": "manifest", + "ui_source_count": 0, + "number": 2, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-parties/issues/2", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "payments", + "name": "Payments", + "repository": "govoplan-payments", + "kind": "manifest", + "ui_source_count": 3, + "number": 3, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-payments/issues/3", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "permits", + "name": "Permits", + "repository": "govoplan-permits", + "kind": "manifest", + "ui_source_count": 0, + "number": 1, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-permits/issues/1", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "policy", + "name": "Policy", + "repository": "govoplan-policy", + "kind": "manifest", + "ui_source_count": 3, + "number": 14, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-policy/issues/14", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "poll", + "name": "Poll", + "repository": "govoplan-poll", + "kind": "manifest", + "ui_source_count": 0, + "number": 5, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-poll/issues/5", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "portal", + "name": "Portal", + "repository": "govoplan-portal", + "kind": "manifest", + "ui_source_count": 2, + "number": 4, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-portal/issues/4", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "postbox", + "name": "Postbox", + "repository": "govoplan-postbox", + "kind": "manifest", + "ui_source_count": 4, + "number": 29, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-postbox/issues/29", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "procurement", + "name": "Procurement", + "repository": "govoplan-procurement", + "kind": "manifest", + "ui_source_count": 0, + "number": 1, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-procurement/issues/1", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "projects", + "name": "Projects", + "repository": "govoplan-projects", + "kind": "manifest", + "ui_source_count": 1, + "number": 4, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-projects/issues/4", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "quick_access", + "name": "Quick Access", + "repository": "govoplan-quick-access", + "kind": "manifest", + "ui_source_count": 2, + "number": 3, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-quick-access/issues/3", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "records", + "name": "Records", + "repository": "govoplan-records", + "kind": "manifest", + "ui_source_count": 4, + "number": 10, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-records/issues/10", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "reporting", + "name": "Reporting", + "repository": "govoplan-reporting", + "kind": "manifest", + "ui_source_count": 3, + "number": 11, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-reporting/issues/11", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "resources", + "name": "Resources", + "repository": "govoplan-resources", + "kind": "manifest", + "ui_source_count": 0, + "number": 1, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-resources/issues/1", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "rest", + "name": "REST Connector", + "repository": "govoplan-rest", + "kind": "manifest", + "ui_source_count": 0, + "number": 1, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-rest/issues/1", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "risk_compliance", + "name": "Risk Compliance", + "repository": "govoplan-risk-compliance", + "kind": "manifest", + "ui_source_count": 1, + "number": 10, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-risk-compliance/issues/10", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "scheduling", + "name": "Scheduling", + "repository": "govoplan-scheduling", + "kind": "manifest", + "ui_source_count": 4, + "number": 10, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-scheduling/issues/10", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "search", + "name": "Search", + "repository": "govoplan-search", + "kind": "manifest", + "ui_source_count": 4, + "number": 6, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-search/issues/6", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "services", + "name": "Services", + "repository": "govoplan-services", + "kind": "manifest", + "ui_source_count": 0, + "number": 2, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-services/issues/2", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "soap", + "name": "SOAP Connector", + "repository": "govoplan-soap", + "kind": "manifest", + "ui_source_count": 0, + "number": 1, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-soap/issues/1", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "tasks", + "name": "Tasks", + "repository": "govoplan-tasks", + "kind": "manifest", + "ui_source_count": 3, + "number": 5, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-tasks/issues/5", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "templates", + "name": "Templates", + "repository": "govoplan-templates", + "kind": "manifest", + "ui_source_count": 1, + "number": 8, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-templates/issues/8", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "tenancy", + "name": "Tenancy", + "repository": "govoplan-tenancy", + "kind": "manifest", + "ui_source_count": 2, + "number": 7, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-tenancy/issues/7", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "tickets", + "name": "Tickets", + "repository": "govoplan-tickets", + "kind": "manifest", + "ui_source_count": 1, + "number": 3, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-tickets/issues/3", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "transparency", + "name": "Transparency", + "repository": "govoplan-transparency", + "kind": "manifest", + "ui_source_count": 0, + "number": 1, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-transparency/issues/1", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "views", + "name": "Views", + "repository": "govoplan-views", + "kind": "manifest", + "ui_source_count": 3, + "number": 6, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-views/issues/6", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "voting", + "name": "Voting", + "repository": "govoplan-voting", + "kind": "manifest", + "ui_source_count": 2, + "number": 8, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-voting/issues/8", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "wiki", + "name": "Wiki", + "repository": "govoplan-wiki", + "kind": "manifest", + "ui_source_count": 1, + "number": 2, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-wiki/issues/2", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "workflow", + "name": "Workflow", + "repository": "govoplan-workflow", + "kind": "manifest", + "ui_source_count": 6, + "number": 17, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-workflow/issues/17", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "workflow_engine", + "name": "Workflow Engine", + "repository": "govoplan-workflow-engine", + "kind": "manifest", + "ui_source_count": 0, + "number": 4, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-workflow-engine/issues/4", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "xrechnung", + "name": "XRechnung", + "repository": "govoplan-xrechnung", + "kind": "manifest", + "ui_source_count": 0, + "number": 3, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-xrechnung/issues/3", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "catalog:govoplan-appointments", + "name": "Appointments", + "repository": "govoplan-appointments", + "kind": "placeholder", + "ui_source_count": 0, + "number": 2, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-appointments/issues/2", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "catalog:govoplan-ledger", + "name": "Ledger", + "repository": "govoplan-ledger", + "kind": "placeholder", + "ui_source_count": 0, + "number": 1, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-ledger/issues/1", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "catalog:govoplan-xoev", + "name": "Xoev", + "repository": "govoplan-xoev", + "kind": "placeholder", + "ui_source_count": 0, + "number": 2, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-xoev/issues/2", + "state_at_verification": "open", + "operation": "created" + }, + { + "scope_id": "catalog:govoplan-xta-osci", + "name": "Xta Osci", + "repository": "govoplan-xta-osci", + "kind": "placeholder", + "ui_source_count": 0, + "number": 1, + "url": "https://git.add-ideas.de/GovOPlaN/govoplan-xta-osci/issues/1", + "state_at_verification": "open", + "operation": "created" + } + ], + "snapshot_date": "2026-09-08", + "initial_review_status": "pending for every scope; this snapshot does not track subsequent issue progress" +} diff --git a/tests/test-devkit-display-labels.mjs b/tests/test-devkit-display-labels.mjs new file mode 100755 index 0000000..36611cf --- /dev/null +++ b/tests/test-devkit-display-labels.mjs @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { test } from "node:test"; +import { auditRepository } from "../tools/devkit/audit-display-labels.mjs"; + +const require = createRequire(resolve(import.meta.dirname, "../../govoplan-core/webui/package.json")); +const ts = require("typescript"); +function fixture(fn) { + const root = mkdtempSync(join(tmpdir(), "govoplan-label-audit-")); + const write = (relative, content) => { const target = join(root, relative); mkdirSync(dirname(target), { recursive: true }); writeFileSync(target, content); }; + try { + write("core/webui/src/i18n/generatedTranslations.ts", 'export const generatedTranslations = { en: { Shared: "Shared" }, de: { Shared: "Gemeinsam" } };'); + return fn(root, write); + } finally { rmSync(root, { recursive: true, force: true }); } +} + +test("plain labels resolve owning registered catalogs, aliases, constants and Core defaults", () => fixture((root, write) => { + write("module/webui/src/i18n/generatedTranslations.ts", 'const en = { Projects: "Projects" }; const de = { Projects: "Projekte" }; export const generatedTranslations = { en, de };'); + write("module/webui/src/module.ts", 'import { generatedTranslations as words } from "./i18n/generatedTranslations"; export const exampleModule: PlatformWebModule = { translations: words };'); + write("module/webui/src/Page.tsx", 'import { PageLayout as Layout, PageTitle } from "@govoplan/core-webui"; const title = "Projects"; export const page = <>Shared;'); + const result = auditRepository(ts, join(root, "module"), join(root, "core")); + assert.equal(result.registration, "registered"); + assert.equal(result.labels.length, 2); + assert.deepEqual(result.findings, []); +})); + +test("a catalog file without module registration cannot mask untranslated titles", () => fixture((root, write) => { + write("module/webui/src/i18n/generatedTranslations.ts", 'export const generatedTranslations = { en: { Projects: "Projects" }, de: { Projects: "Projekte" } };'); + write("module/webui/src/module.ts", 'export const exampleModule = { id: "example" };'); + write("module/webui/src/Page.tsx", 'import * as Core from "@govoplan/core-webui"; export const page = ;'); + const result = auditRepository(ts, join(root, "module"), join(root, "core")); + assert(result.findings.some((item) => item.code === "catalog-not-registered")); + assert.deepEqual(result.labels[0].missing_locales, ["en", "de"]); +})); + +test("only known static display slots fail; markers and runtime data remain separate", () => fixture((root, write) => { + write("module/webui/src/Page.tsx", 'import { PageLayout } from "@govoplan/core-webui"; export const page = <>;'); + const result = auditRepository(ts, join(root, "module"), join(root, "core")); + assert.equal(result.findings.length, 1); + assert.equal(result.findings[0].text, "Untranslated"); + assert.equal(result.review.length, 1); + assert.equal(result.review[0].code, "dynamic-display-slot"); +})); + +test("dynamic translation registration is reported for review, not falsely missing", () => fixture((root, write) => { + write("module/webui/src/module.ts", 'export const exampleModule: PlatformWebModule = { translations: configuredCatalog() };'); + write("module/webui/src/Page.tsx", 'export const page = Example;'); + const result = auditRepository(ts, join(root, "module"), join(root, "core")); + assert.equal(result.registration, "dynamic"); + assert.deepEqual(result.findings, []); + assert(result.review.some((item) => item.code === "dynamic-catalog-review")); +})); diff --git a/tests/test-heading-help.mjs b/tests/test-heading-help.mjs new file mode 100755 index 0000000..773e92d --- /dev/null +++ b/tests/test-heading-help.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { findDetachedDocumentation } from "../tools/checks/check-heading-help.mjs"; +import "./test-heading-translations.mjs"; + +const inspect = (source) => findDetachedDocumentation([{ path: "/fixture/Page.tsx", source }]).findings; +const book = ''; + +test("heading and text contracts accept contextual help", () => { + for (const component of ["PageHeader", "PageLayout", "AdminPageLayout", "Card", "Dialog", "PageActionBar", "WorkspaceActionBar"]) { + assert.equal(inspect(`const Page=()=> <${component} title="Topic" titleHelp={${book}} />`).length, 0, component); + } + assert.equal(inspect(`const Page=()=> Topic`).length, 0); + assert.equal(inspect(`const Page=()=> Existing label`).length, 0); +}); + +test("detached action and body links are rejected", () => { + for (const source of [``, ``, `
${book}
`, ``]) { + assert.equal(inspect(`const Page=()=> ${source}`).length, 1, source); + } +}); + +test("aliases, conditional help and simple local references remain checked", () => { + assert.equal(inspect(`import {DocumentationHelpLink as Book, Card as Box} from '@govoplan/core-webui'; const Page=()=> } />`).length, 0); + assert.equal(inspect(`import {DocumentationHelpLink as Book} from '@govoplan/core-webui'; const Page=()=>
`).length, 1); + assert.equal(inspect(`const help=${book}; const Page=()=> `).length, 0); + assert.equal(inspect(`const help=${book}; const Page=()=> <>`).length, 1); + assert.equal(inspect(`const help=${book}; const again=help; const Page=()=> `).length, 0); +}); + +test("empty anchors, unknown contracts and nested interactive elements fail", () => { + for (const source of [``, ``, ` `, ``, `${book}}/>`]) { + assert.equal(inspect(`const Page=()=> ${source}`).length, 1, source); + } +}); + +test("namespace and explicit component default imports cannot bypass placement", () => { + for (const source of [ + `import * as UI from '@govoplan/core-webui'; const Page=()=>
`, + `import Book from './components/help/DocumentationHelpLink'; const Page=()=>
`, + ]) assert.equal(inspect(source).length, 1, source); + assert.equal(inspect(`import * as UI from '@govoplan/core-webui'; const Page=()=> }/>`).length, 0); + assert.equal(inspect(`import Book from './components/help/DocumentationHelpLink'; import Label from './components/help/TextWithHelp'; const Page=()=> `).length, 0); + assert.equal(findDetachedDocumentation([{ path: "/fixture/Page.tsx", source: `import Book from './business/Book'; const Page=()=>
` }]).links, 0); +}); + +test("outer interactive containers and statically absent text are rejected", () => { + for (const source of [ + ``, + ``, + ``, + ``, + `{null}`, + `{/* Topic */}`, + `{undefined}`, + ``, + ``, + ]) assert.equal(inspect(`const Page=()=> ${source}`).length, 1, source); + assert.equal(inspect(`const help=${book}; const Page=()=> `).length, 1); + assert.equal(inspect(`const title=null; const Page=()=> `).length, 1); + assert.equal(inspect(`const Page=()=> `).length, 0); + assert.equal(inspect(`const Page=()=> `).length, 0); + assert.equal(inspect(`let title=""; title=translateText(key); const Page=()=> `).length, 0); +}); diff --git a/tests/test-heading-translations.mjs b/tests/test-heading-translations.mjs new file mode 100755 index 0000000..113b3d8 --- /dev/null +++ b/tests/test-heading-translations.mjs @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { resolve } from "node:path"; +import { test } from "node:test"; + +const root = resolve(import.meta.dirname, "../.."); +const require = createRequire(resolve(root, "govoplan-core/webui/package.json")); +const { buildSync } = require("esbuild"); +const labels = { + approvals: { "Approval requests": "Genehmigungsanträge" }, + forms: { "Form meaning": "Formularbedeutung" }, + "forms-runtime": { "Form instance": "Formularinstanz", "Forms runtime": "Formularlaufzeit" }, + helpdesk: { "Helpdesk profiles": "Helpdesk-Profile" }, + portal: { "Service directory": "Leistungsverzeichnis" }, + projects: { Projects: "Projekte" }, + reporting: { Reporting: "Reporting" }, + "risk-compliance": { "Risk Compliance": "Risiko und Compliance" }, + scheduling: { Scheduling: "Terminplanung" }, + tickets: { Tickets: "Tickets" }, + voting: { Ballots: "Abstimmungen" }, + wiki: { Wiki: "Wiki" }, + workflow: { Workflows: "Workflows", Workflow: "Workflow" }, + idm: { + "Direct changes to this governed function are": "Direkte Änderungen an dieser gesteuerten Funktion sind", + "emergency overrides": "Notfallübersteuerungen", + ". Use a request or grant above for the normal process.": ". Verwenden Sie für den regulären Prozess einen Antrag oder eine Vergabe.", + }, +}; + +// Render the actual Core heading/locale contract with one owning module at a +// time. The in-memory bundle creates no shared component-build artifacts. +const names = Object.keys(labels); +const imports = names.map((name, index) => `import { generatedTranslations as t${index} } from ${JSON.stringify(resolve(root, `govoplan-${name}/webui/src/i18n/generatedTranslations.ts`))};`).join("\n"); +const { outputFiles } = buildSync({ + stdin: { + contents: `${imports} + import { renderToStaticMarkup } from 'react-dom/server'; + import { PlatformLanguageProvider } from ${JSON.stringify(resolve(root, "govoplan-core/webui/src/i18n/LanguageContext.tsx"))}; + import PageTitle from ${JSON.stringify(resolve(root, "govoplan-core/webui/src/components/PageTitle.tsx"))}; + import DocumentationHelpLink from ${JSON.stringify(resolve(root, "govoplan-core/webui/src/components/help/DocumentationHelpLink.tsx"))}; + export const catalogs = [${names.map((_, index) => `t${index}`).join(",")}]; + export function heading(index, language, label) { + return renderToStaticMarkup( + }>{label} + ); + }`, + loader: "tsx", + resolveDir: resolve(root, "govoplan-core/webui"), + }, + bundle: true, + write: false, + platform: "node", + format: "cjs", + jsx: "automatic", + external: ["react", "react-dom/server"], + logLevel: "silent", +}); +const compiled = { exports: {} }; +new Function("require", "module", "exports", outputFiles[0].text)(require, compiled, compiled.exports); +const { catalogs, heading } = compiled.exports; + +test("new contextual labels render from their owning EN/DE catalogues", async (context) => { + for (const [index, name] of names.entries()) { + await context.test(name, () => { + const moduleSource = readFileSync(resolve(root, `govoplan-${name}/webui/src/module.ts`), "utf8"); + assert.match(moduleSource, /import\s*\{\s*generatedTranslations\s*\}\s*from\s*["']\.\/i18n\/generatedTranslations["']/); + assert.match(moduleSource, /\btranslations(?:\s*:\s*generatedTranslations)?\s*,/); + for (const [english, german] of Object.entries(labels[name])) { + assert.equal(catalogs[index].en[english], english); + assert.equal(catalogs[index].de[english], german); + for (const [language, expected] of [["en", english], ["de", german]]) { + const markup = heading(index, language, english); + assert(markup.includes(`

${expected}

`), `${name}: ${language} contextual heading`); + assert(markup.includes(language === "de" ? "Benutzerdokumentation öffnen" : "Open user documentation")); + } + } + }); + } +}); diff --git a/tests/test_devkit_audit_scope.py b/tests/test_devkit_audit_scope.py new file mode 100755 index 0000000..abf19e8 --- /dev/null +++ b/tests/test_devkit_audit_scope.py @@ -0,0 +1,438 @@ +"""Audit the selected fixture workspace, never a fuller neighboring checkout.""" + +from argparse import Namespace +import importlib.util +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +from types import ModuleType, SimpleNamespace + +import pytest + + +META_ROOT = Path(__file__).resolve().parents[1] +SCRIPT = META_ROOT / "tools/inventory/platform-interface-inventory.py" +WEBUI_SCRIPT = META_ROOT / "tools/inventory/extract-webui-structure.mjs" +SPEC = importlib.util.spec_from_file_location("audit_scope_inventory", SCRIPT) +assert SPEC is not None and SPEC.loader is not None +inventory = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(inventory) + +sys.path.insert(0, str(META_ROOT / "tools/devkit")) +from govoplan_devkit import docs, issues, runner # noqa: E402 +from govoplan_devkit.workspace import Project, Repository # noqa: E402 + + +def write(path, content): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +@pytest.fixture +def workspaces(tmp_path): + selected = tmp_path / "selected" + default = tmp_path / "fuller-default" + meta = tmp_path / "legacy-siblings/govoplan" + selected.mkdir() + meta.mkdir(parents=True) + catalog = { + "default_parent": str(default), + "repositories": [ + {"name": "govoplan-core", "path": "govoplan-core"}, + {"name": "govoplan-example", "path": "nested/example"}, + {"name": "govoplan-optional", "path": "govoplan-optional"}, + ], + } + write(meta / "repositories.json", json.dumps(catalog)) + write( + selected / "nested/example/webui/src/Page.tsx", + "export const page =

SELECTED_ONLY

;\n", + ) + write( + default / "nested/example/webui/src/Page.tsx", + "export const page =

DEFAULT_ONLY

;\n", + ) + write( + default / "govoplan-optional/webui/src/Page.tsx", + "export const page =

DEFAULT_OPTIONAL

;\n", + ) + for item in catalog["repositories"]: + (default / item["path"] / "src").mkdir(parents=True) + return selected, default, meta, catalog + + +def test_explicit_partial_root_beats_fuller_legacy_discovery(workspaces, monkeypatch): + selected, default, meta, catalog = workspaces + monkeypatch.setattr(inventory, "META_ROOT", meta) + assert inventory._resolve_workspace_root(catalog) == default + assert inventory._resolve_workspace_root(catalog, selected) == selected + with pytest.raises(ValueError, match="existing directory"): + inventory._resolve_workspace_root(catalog, selected / "missing") + + +@pytest.mark.parametrize("relative", ["../fuller-default", "/absolute/path"]) +def test_repository_paths_cannot_escape_explicit_root(workspaces, relative): + selected, _, _, _ = workspaces + with pytest.raises(ValueError, match="inside the selected workspace"): + inventory._validate_repository_roots( + {"repositories": [{"path": relative}]}, selected + ) + + +def test_linked_repository_cannot_borrow_default_sources(workspaces): + selected, default, _, catalog = workspaces + (selected / "govoplan-core").symlink_to( + default / "govoplan-core", target_is_directory=True + ) + with pytest.raises(ValueError, match="escapes the selected workspace"): + inventory._validate_repository_roots(catalog, selected) + + +def test_python_forwards_selected_root_and_configured_node(workspaces, monkeypatch): + selected, _, meta, _ = workspaces + monkeypatch.setattr(inventory, "META_ROOT", meta) + monkeypatch.setenv("NODE", "/selected/toolchain/node") + calls = [] + + def run(argv, **kwargs): + calls.append((argv, kwargs)) + return SimpleNamespace(stdout=json.dumps({"workspaceRoot": str(selected)})) + + monkeypatch.setattr(inventory.subprocess, "run", run) + assert inventory._extract_webui(selected)["workspaceRoot"] == str(selected) + argv, options = calls[0] + assert argv == [ + "/selected/toolchain/node", + str(meta / "tools/inventory/extract-webui-structure.mjs"), + str(meta), + "--workspace-root", + str(selected), + ] + assert options == {"check": True, "capture_output": True, "text": True} + + +def test_python_rejects_webui_result_from_another_root(workspaces, monkeypatch): + selected, default, _, _ = workspaces + monkeypatch.setattr( + inventory.subprocess, + "run", + lambda *a, **k: SimpleNamespace( + stdout=json.dumps({"workspaceRoot": str(default)}) + ), + ) + with pytest.raises(ValueError, match="did not confirm"): + inventory._extract_webui(selected) + + +def test_main_forwards_same_root_to_every_collector(workspaces, monkeypatch): + selected, _, meta, catalog = workspaces + monkeypatch.setattr(inventory, "META_ROOT", meta) + roots = [] + monkeypatch.setattr( + inventory, "_extract_webui", lambda root: roots.append(root) or {} + ) + monkeypatch.setattr( + inventory, + "_extract_backend_endpoints", + lambda data, root: roots.append(root) or [], + ) + monkeypatch.setattr( + inventory, "_extract_manifests", lambda data, root: roots.append(root) or [] + ) + monkeypatch.setattr(inventory, "_load_endpoint_declarations", lambda _: {}) + monkeypatch.setattr(inventory, "_load_high_risk_help_baseline", lambda _: {}) + monkeypatch.setattr(inventory, "_assemble_inventory", lambda **kwargs: {}) + monkeypatch.setattr(inventory, "_render_markdown", lambda data: "fixture\n") + output = selected / "output" + monkeypatch.setattr( + sys, + "argv", + [str(SCRIPT), "--workspace-root", str(selected), "--output-dir", str(output)], + ) + assert inventory.main() == 0 + assert roots == [selected, selected, selected] + report = json.loads((output / "platform-interface-inventory.json").read_text()) + assert report["workspace_root"] == str(selected) + assert report["workspace_selection"] == "explicit" + + +def install_fixture_compiler(root): + compiler = META_ROOT.parent / "govoplan-core/webui/node_modules/typescript" + if not compiler.is_dir() or not shutil.which("node"): + pytest.skip("Node and the installed Core TypeScript parser are required") + target = root / "govoplan-core/webui/node_modules/typescript" + target.parent.mkdir(parents=True, exist_ok=True) + # Parser dependencies may be shared; audited source checkouts may not. + target.symlink_to(compiler, target_is_directory=True) + + +def collect_webui(meta, selected=None): + argv = [shutil.which("node") or "node", str(WEBUI_SCRIPT), str(meta)] + if selected is not None: + argv += ["--workspace-root", str(selected)] + return subprocess.run(argv, capture_output=True, text=True, timeout=30) + + +def test_javascript_explicit_root_is_authoritative_and_legacy_cli_still_works( + workspaces, +): + selected, default, meta, _ = workspaces + install_fixture_compiler(selected) + install_fixture_compiler(default) + explicit = collect_webui(meta, selected) + assert explicit.returncode == 0, explicit.stderr + report = json.loads(explicit.stdout) + assert report["workspaceRoot"] == str(selected) + assert [item["value"] for item in report["visibleText"]] == ["SELECTED_ONLY"] + assert report["visibleText"][0]["file"] == "webui/src/Page.tsx" + legacy = collect_webui(meta) + assert legacy.returncode == 0, legacy.stderr + assert json.loads(legacy.stdout)["workspaceRoot"] == str(default) + assert {item["value"] for item in json.loads(legacy.stdout)["visibleText"]} == { + "DEFAULT_ONLY", + "DEFAULT_OPTIONAL", + } + + +def test_javascript_does_not_borrow_missing_core_dependencies(workspaces): + selected, default, meta, _ = workspaces + install_fixture_compiler(default) + result = collect_webui(meta, selected) + assert result.returncode != 0 + assert ( + str(selected / "govoplan-core/webui/node_modules/typescript") in result.stderr + ) + assert not result.stdout + + +def test_javascript_rejects_source_root_linked_outside_workspace(workspaces): + selected, default, meta, _ = workspaces + install_fixture_compiler(selected) + linked = selected / "govoplan-optional/webui/src" + linked.parent.mkdir(parents=True) + linked.symlink_to(default / "govoplan-optional/webui/src", target_is_directory=True) + result = collect_webui(meta, selected) + assert result.returncode != 0 + assert "source root escapes the selected workspace" in result.stderr + + +def test_backend_does_not_borrow_optional_endpoints(workspaces): + selected, default, _, catalog = workspaces + source = "from fastapi import APIRouter\nrouter = APIRouter()\n@router.get('/selected')\ndef endpoint(): pass\n" + write(selected / "nested/example/src/routes.py", source) + write( + default / "govoplan-optional/src/routes.py", source.replace("selected", "other") + ) + endpoints = inventory._extract_backend_endpoints(catalog, selected) + assert [item["path"] for item in endpoints] == ["/selected"] + (selected / "nested/example/src/foreign.py").symlink_to( + default / "govoplan-optional/src/routes.py" + ) + with pytest.raises(ValueError, match="Backend source path escapes"): + inventory._extract_backend_endpoints(catalog, selected) + + +def test_manifests_require_selected_core_sources(workspaces, monkeypatch): + selected, default, _, catalog = workspaces + write( + default / "govoplan-core/src/govoplan_core/core/platform_interfaces.py", + "raise AssertionError('foreign source imported')", + ) + monkeypatch.syspath_prepend(str(default / "govoplan-core/src")) + with pytest.raises(ValueError, match="requires Core interface sources"): + inventory._extract_manifests(catalog, selected) + + +def test_cached_application_import_cannot_replace_missing_checkout( + workspaces, monkeypatch +): + selected, default, _, _ = workspaces + # Meta's own devkit/release packages may audit another workspace. + tooling = ModuleType("govoplan_release") + tooling.__file__ = str(default / "tools/release/govoplan_release/__init__.py") + modules = {"govoplan_release": tooling, "govoplan_devkit.docs": docs} + # Isolate the fixture from application packages collected by unrelated + # suites, without removing or replacing those real cached imports. + monkeypatch.setattr(inventory, "sys", SimpleNamespace(modules=modules)) + inventory._assert_workspace_imports(selected) + application = ModuleType("govoplan_audit_fixture") + application.__file__ = str( + default / "govoplan-optional/src/govoplan_audit_fixture/__init__.py" + ) + modules["govoplan_audit_fixture"] = application + with pytest.raises(ValueError, match="outside the selected inventory workspace"): + inventory._assert_workspace_imports(selected) + + +def test_partial_manifest_collection_uses_selected_sources_in_fresh_process(workspaces): + selected, default, _, catalog = workspaces + core = selected / "govoplan-core/src/govoplan_core" + write(core / "__init__.py", "") + write(core / "core/__init__.py", "") + write( + core / "core/platform_interfaces.py", + "def manifest_interface_catalog(manifest): return {'selected': True}\n", + ) + write( + default / "govoplan-core/src/govoplan_core/__init__.py", + "raise AssertionError('foreign Core loaded')\n", + ) + module = selected / "nested/example/src/govoplan_example" + write(module / "__init__.py", "") + write(module / "backend/__init__.py", "") + manifest = ( + "from types import SimpleNamespace as S\n" + "def get_manifest():\n" + " return S(id='selected', name='Selected', version='1', dependencies=(), " + "optional_dependencies=(), required_capabilities=(), provides_interfaces=(), " + "capability_factories={}, permissions=(), documentation=(), architecture=None, " + "information_governance=S(to_dict=lambda: {}), frontend=None)\n" + ) + write(module / "backend/manifest.py", manifest) + write( + default / "govoplan-optional/src/govoplan_optional/backend/manifest.py", + manifest.replace("selected", "foreign"), + ) + code = ( + "import importlib.util, json; from pathlib import Path; " + f"spec=importlib.util.spec_from_file_location('fixture', {str(SCRIPT)!r}); " + "module=importlib.util.module_from_spec(spec); spec.loader.exec_module(module); " + f"print(json.dumps(module._extract_manifests({catalog!r}, Path({str(selected)!r}))))" + ) + env = { + **os.environ, + "PYTHONPATH": os.pathsep.join( + str(default / item["path"] / "src") for item in catalog["repositories"] + ), + } + result = subprocess.run( + [sys.executable, "-c", code], + env=env, + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, result.stderr + manifests = json.loads(result.stdout) + assert [manifest["id"] for manifest in manifests] == ["selected"] + assert manifests[0]["interface_catalog"] == {"selected": True} + + +def doc_plan(workspace, monkeypatch): + repos = tuple( + Repository(name, workspace / name) + for name in ("govoplan", "govoplan-core", "govoplan-example") + ) + project = Project("fixture", repos, {}) + monkeypatch.setattr(docs, "load_project", lambda *a: project) + args = Namespace( + workspace_root=workspace, + project=None, + state_dir=workspace.parent / "state", + repo=["govoplan-example"], + changed=False, + ) + return docs.build_doc_stages(args) + + +def test_docs_plan_forwards_root_and_persists_all_limitations(workspaces, monkeypatch): + selected, _, _, _ = workspaces + stages = doc_plan(selected, monkeypatch) + by_id = {stage["id"]: stage for stage in stages} + for stage_id in ( + "docs.manifests", + "docs.interface-inventory", + "docs.plain-display-labels", + ): + argv = by_id[stage_id]["argv"] + assert argv[argv.index("--workspace-root") + 1] == str(selected) + assert by_id["docs.translation-structure"]["argv"][1] == str( + selected / "govoplan-core/webui/scripts/audit-i18n-structural.mjs" + ) + assert issues.coverage_notes(stages) == docs.LIMITATIONS + assert by_id["docs.plain-display-labels"]["argv"][-2:] == [ + "--repo", + "govoplan-example", + ] + + +def test_docs_limitations_survive_real_receipt_and_issue_evidence( + workspaces, monkeypatch, tmp_path +): + selected, _, _, _ = workspaces + stages = doc_plan(selected, monkeypatch) + repo = selected / "example" + repo.mkdir() + subprocess.run(["git", "init", "-q", str(repo)], check=True, timeout=10) + write(repo / "source.txt", "fixture\n") + subprocess.run( + ["git", "-C", str(repo), "add", "source.txt"], check=True, timeout=10 + ) + subprocess.run( + [ + "git", + "-C", + str(repo), + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "commit", + "-qm", + "fixture", + ], + check=True, + timeout=10, + ) + project = tmp_path / "project.json" + write( + project, + json.dumps( + { + "schema_version": 1, + "name": "fixture", + "repositories": [{"name": "example", "path": "example"}], + "checks": [], + "profiles": {}, + } + ), + ) + args = Namespace( + workspace_root=selected, + project=project, + state_dir=tmp_path / "state", + dry_run=False, + jobs=2, + profile="docs", + resume=None, + ) + for stage in stages: + stage.update( + argv=[sys.executable, "-c", "print('fixture audit')"], + cwd=str(repo), + deps=[], + resources=[], + timeout_seconds=10, + ) + monkeypatch.setattr(docs, "build_doc_stages", lambda _: stages) + monkeypatch.setattr( + runner, "environment_fingerprint", lambda *a, **k: "fixture-env" + ) + result = docs.audit(args) + assert result["status"] == "passed", result + assert all(result["summary"].count(note) == 1 for note in docs.LIMITATIONS) + receipt = runner.read_receipt(selected, args.state_dir, result["run_id"]) + assert issues.coverage_notes(receipt["stages"]) == docs.LIMITATIONS + evidence = issues.evidence_record(result["run_id"], args) + assert evidence["coverage_notes"] == docs.LIMITATIONS + assert evidence["source_state"] == "matches-current" + target = issues.NoteTarget( + repo, "https://gitea.example.invalid", "fixture", "example", 1 + ) + _, body = issues.render_note( + {"summary": [], "next": [], "body": ""}, evidence, target, "fixture" + ) + assert all(note in body for note in docs.LIMITATIONS) diff --git a/tests/test_devkit_cancellation.py b/tests/test_devkit_cancellation.py new file mode 100755 index 0000000..133d587 --- /dev/null +++ b/tests/test_devkit_cancellation.py @@ -0,0 +1,88 @@ +"""Signals during snapshot probes must not become passing run evidence.""" + +import os +import signal + +import pytest + +import test_devkit_runner as fixtures +from govoplan_devkit import runner +from govoplan_devkit.checkpoints import Checkpoints + +example = fixtures.example + + +@pytest.mark.parametrize("interruption", [signal.SIGINT, signal.SIGTERM]) +@pytest.mark.parametrize("probe", ["source", "environment"]) +def test_interrupt_during_final_snapshot_is_not_a_passing_run( + example, monkeypatch, interruption, probe +): + args, repo = example + calls = {"source": 0, "environment": 0} + events, interruptions = [], [] + args.on_progress = events.append + original = Checkpoints.source + + def reached(kind): + calls[kind] += 1 + if kind == probe and events and events[-1]["phase"] == "finalizing": + # Intermediate checkpoint probes are also real verification. Target + # finalization explicitly, while the runner owns signal handlers. + interruptions.append(kind) + os.kill(os.getpid(), interruption) + + def source(self, *values, **kwargs): + reached("source") + return original(self, *values, **kwargs) + + def environment(*_args, **_kwargs): + reached("environment") + return "fixture-environment" + + monkeypatch.setattr(Checkpoints, "source", source) + monkeypatch.setattr(runner, "environment_fingerprint", environment) + result = runner.run_checks(args, [fixtures.stage(repo)]) + assert interruptions == [probe] + assert calls[probe] >= 2 + assert result["stages"][0]["status"] == "passed" + assert result["status"] == "interrupted" + assert result["_exit_code"] != 0 + receipt = runner.read_receipt(args.workspace_root, args.state_dir, result["run_id"]) + assert receipt["status"] == "interrupted" + + +@pytest.mark.parametrize("interruption", [signal.SIGINT, signal.SIGTERM]) +@pytest.mark.parametrize("probe", ["source", "environment"]) +def test_preparation_interrupt_stops_at_probe_boundary_without_running_checks( + example, monkeypatch, interruption, probe +): + args, repo = example + calls = [] + original = Checkpoints.source + + def reached(kind): + calls.append(kind) + if kind == probe: + os.kill(os.getpid(), interruption) + + def source(self, *values, **kwargs): + reached("source") + return original(self, *values, **kwargs) + + def environment(*_args, **_kwargs): + reached("environment") + return "fixture-environment" + + def must_not_execute(*args, **kwargs): + pytest.fail("A cancelled preparation must not start a check") + + monkeypatch.setattr(Checkpoints, "source", source) + monkeypatch.setattr(runner, "environment_fingerprint", environment) + monkeypatch.setattr(runner, "execute_stage", must_not_execute) + result = runner.run_checks(args, [fixtures.stage(repo)]) + assert calls == (["source"] if probe == "source" else ["source", "environment"]) + assert result["status"] == "interrupted" + assert result["snapshot_verified"] is False + assert result["_exit_code"] != 0 + receipt = runner.read_receipt(args.workspace_root, args.state_dir, result["run_id"]) + assert receipt["status"] == "interrupted" diff --git a/tests/test_devkit_catalog.py b/tests/test_devkit_catalog.py new file mode 100755 index 0000000..94def5c --- /dev/null +++ b/tests/test_devkit_catalog.py @@ -0,0 +1,451 @@ +"""Bounded planning fixtures: no application imports, compilers or servers.""" + +from __future__ import annotations + +import argparse +import json +import shutil +from pathlib import Path +import sys +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools/devkit")) +from govoplan_devkit.catalog import ( + _expanded_repositories, + build_stages, + module_ui_stages, +) +from govoplan_devkit.docs import build_doc_stages +from govoplan_devkit.workspace import Project, Repository + + +class CatalogTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="govoplan-devkit-catalog-") + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.core = Repository("govoplan-core", self.root / "govoplan-core", ("core",)) + self.meta = Repository("govoplan", self.root / "govoplan", ("meta",)) + self.module = Repository( + "govoplan-example", self.root / "govoplan-example", ("example",) + ) + self.project = Project("fixture", (self.meta, self.core, self.module), {}) + for repo in self.project.repositories: + repo.path.mkdir() + (self.meta.path / "tools/checks").mkdir(parents=True) + shutil.copyfile( + Path(__file__).resolve().parents[1] / "tools/checks/focused-phases.json", + self.meta.path / "tools/checks/focused-phases.json", + ) + + def write(self, relative, content): + path = self.root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + def plan(self, profile, repos=None, changed=False): + with patch("govoplan_devkit.workspace.load_project", return_value=self.project): + return build_stages(self.root, profile, repos or [], changed) + + def test_ui_compiles_once_and_quick_does_not_compile(self): + self.write( + "govoplan-core/webui/package.json", + json.dumps( + {"scripts": {"test:components": "node scripts/run-component-tests.mjs"}} + ), + ) + self.write("govoplan-core/webui/scripts/run-component-tests.mjs", "// fixture") + self.assertFalse( + any( + "run-component-tests.mjs" in " ".join(item["argv"]) + for item in self.plan("quick") + ) + ) + matches = [ + item + for item in self.plan("ui") + if "run-component-tests.mjs" in " ".join(item["argv"]) + ] + self.assertEqual(len(matches), 1) + self.assertEqual(matches[0]["id"], "core.component-batch") + + def test_module_script_metadata_is_bounded_and_deduplicated(self): + self.write( + "govoplan-example/webui/package.json", + json.dumps( + { + "scripts": { + "test:interface-pattern": "node scripts/test-interface-pattern-language.mjs", + "test:source": "node --test tests/source.test.mjs", + "test:dangerous-chain": "node tests/source.test.mjs && npm run dev", + "test:flags": "node --eval 'startServer()'", + } + } + ), + ) + self.write( + "govoplan-example/webui/scripts/test-interface-pattern-language.mjs", + "// source-only fixture", + ) + self.write( + "govoplan-example/webui/tests/source.test.mjs", "// source-only fixture" + ) + stages = module_ui_stages(self.module, reason="fixture") + self.assertEqual(len(stages), 2) + self.assertEqual(len({item["id"] for item in stages}), 2) + self.assertTrue(all(item["argv"][0] == "{node}" for item in stages)) + self.assertFalse(any("&&" in item["argv"] for item in stages)) + + def test_full_is_never_narrowed_by_repo_filter(self): + result = self.plan("full", ["example"]) + self.assertEqual( + [item["id"] for item in result], + [ + "focused." + identity + for identity in ( + "preflight", + "tooling", + "backend", + "core-ui", + "module-builds", + "browser", + "module-ui", + ) + ], + ) + self.assertEqual( + [item["after"] for item in result], + [[], *[[item["id"]] for item in result[:-1]]], + ) + self.assertTrue(all(item["deps"] == [] for item in result)) + self.assertTrue( + all( + item["argv"] + == [ + "bash", + str(self.meta.path / "tools/checks/check-focused.sh"), + "--phase", + item["id"].removeprefix("focused."), + ] + for item in result + ) + ) + self.assertIn("webui:govoplan-example", result[-1]["resources"]) + self.assertIn("backend:test-state", result[0]["resources"]) + + def test_changed_empty_is_not_full_verification(self): + with ( + patch("govoplan_devkit.workspace.load_project", return_value=self.project), + patch("govoplan_devkit.workspace.selected_repositories", return_value=[]), + ): + self.assertEqual(build_stages(self.root, "quick", [], True), []) + self.assertEqual(len(build_stages(self.root, "full", [], True)), 7) + + def test_full_ui_scope_excludes_backend_only_but_includes_all_ui_owners(self): + backend = Repository( + "govoplan-backend-only", self.root / "govoplan-backend-only" + ) + backend.path.mkdir() + (self.module.path / "webui").mkdir() + (self.core.path / "webui").mkdir() + project = Project("fixture", (*self.project.repositories, backend), {}) + with patch("govoplan_devkit.workspace.load_project", return_value=project): + checks = build_stages(self.root, "full", ["example"], False) + for check in checks[:3]: + self.assertNotIn("inputs", check) + for check in checks[3:]: + self.assertEqual( + check["inputs"]["repos"], + ["govoplan", "govoplan-core", "govoplan-example"], + ) + (backend.path / "webui").mkdir() + with patch("govoplan_devkit.workspace.load_project", return_value=project): + replanned = build_stages(self.root, "full", [], False) + self.assertIn(backend.name, replanned[3]["inputs"]["repos"]) + + def test_full_missing_checkout_explicitly_falls_back_to_workspace_inputs(self): + missing = Repository("govoplan-missing", self.root / "govoplan-missing") + project = Project("fixture", (*self.project.repositories, missing), {}) + with patch("govoplan_devkit.workspace.load_project", return_value=project): + checks = build_stages(self.root, "full", [], False) + self.assertTrue(all("inputs" not in check for check in checks)) + self.assertTrue( + all( + any("workspace-wide" in note for note in check["coverage_notes"]) + for check in checks[3:] + ) + ) + + def test_full_requires_authoritative_phase_metadata(self): + (self.meta.path / "tools/checks/focused-phases.json").write_text("{}") + with self.assertRaisesRegex(ValueError, "phase metadata"): + self.plan("full") + + def test_native_ui_guards_and_component_batch_share_safe_ui_owner_scope(self): + backend = Repository( + "govoplan-backend-only", self.root / "govoplan-backend-only" + ) + backend.path.mkdir() + (self.module.path / "webui").mkdir() + (self.core.path / "webui").mkdir() + project = Project("fixture", (*self.project.repositories, backend), {}) + with patch("govoplan_devkit.workspace.load_project", return_value=project): + checks = build_stages(self.root, "ui", ["example"], False) + for check in checks: + if check["id"] in { + "jsx-value-imports", + "heading-help", + "core.component-batch", + }: + self.assertEqual( + check["inputs"]["repos"], + ["govoplan", "govoplan-core", "govoplan-example"], + ) + else: + self.assertNotIn("inputs", check) + + def test_unregistered_src_or_webui_disables_all_native_reuse(self): + for directory in ("src", "webui"): + with self.subTest(directory=directory): + unknown = self.root / "govoplan-unregistered" / directory + unknown.mkdir(parents=True) + try: + for profile in ("quick", "ui", "backend", "full"): + checks = self.plan(profile) + self.assertTrue(checks) + self.assertTrue( + all(check["reuse"] == "never" for check in checks) + ) + self.assertTrue( + all( + any( + "Unregistered sibling" in note + for note in check["coverage_notes"] + ) + for check in checks + ) + ) + finally: + unknown.rmdir() + unknown.parent.rmdir() + (self.root / "govoplan-unused-empty").mkdir() + self.assertTrue(all("reuse" not in check for check in self.plan("full"))) + + def test_unregistered_broken_source_symlink_does_not_allow_reuse(self): + unknown = self.root / "govoplan-unregistered" + unknown.mkdir() + (unknown / "src").symlink_to(self.root / "missing") + self.assertTrue(all(check["reuse"] == "never" for check in self.plan("full"))) + + def test_unknown_repo_is_not_silently_ignored(self): + with self.assertRaisesRegex(ValueError, "Unknown repository"): + self.plan("ui", ["not-a-repo"]) + + def test_changed_provider_selects_transitive_declared_consumers(self): + other = Repository("govoplan-other", self.root / "govoplan-other") + final = Repository("govoplan-final", self.root / "govoplan-final") + project = Project("fixture", (self.meta, self.module, other, final), {}) + self.write("govoplan/tools/release/govoplan_release/contracts.py", "# fixture") + for name in ("example", "other", "final"): + self.write(f"govoplan-{name}/src/fixture/backend/manifest.py", "# fixture") + + def contract(_path, repo_name): + gives = { + self.module.name: ["first"], + other.name: ["second"], + final.name: [], + }[repo_name] + needs = { + self.module.name: [], + other.name: ["first"], + final.name: ["second"], + }[repo_name] + return SimpleNamespace( + repo=repo_name, + provides_interfaces=[SimpleNamespace(name=value) for value in gives], + requires_interfaces=[SimpleNamespace(name=value) for value in needs], + ) + + with patch.dict( + sys.modules, + { + "govoplan_release.contracts": SimpleNamespace( + parse_manifest_contract=contract + ) + }, + ): + selected, reason = _expanded_repositories( + project, [self.module], changed=True + ) + self.assertEqual( + [repo.name for repo in selected], [self.module.name, other.name, final.name] + ) + self.assertIn("declared interface consumers", reason) + + def test_generic_dependency_closure_includes_filtered_prerequisites(self): + config = { + "schema_version": 1, + "name": "generic", + "repositories": [ + {"name": "one", "path": "one"}, + {"name": "two", "path": "two"}, + ], + "checks": [ + { + "id": "compile", + "argv": ["{node}", "compile.mjs"], + "cwd": "one", + "repos": ["two"], + }, + { + "id": "test", + "argv": ["{node}", "test.mjs"], + "cwd": "one", + "deps": ["compile"], + "repos": ["one"], + }, + { + "id": "other", + "argv": ["{python}", "test.py"], + "cwd": "two", + "repos": ["two"], + }, + ], + "profiles": {"quick": ["test", "other"]}, + } + self.write("project.json", json.dumps(config)) + result = build_stages( + self.root, "quick", ["one"], False, self.root / "project.json" + ) + self.assertEqual([item["id"] for item in result], ["compile", "test"]) + self.assertEqual(result[1]["deps"], ["compile"]) + self.assertEqual(result[1]["cwd"], str(self.root / "one")) + + def test_generic_cycles_and_duplicate_ids_fail(self): + config = { + "schema_version": 1, + "name": "generic", + "repositories": [{"name": "one", "path": "one"}], + "checks": [ + {"id": "one", "argv": ["test"], "deps": ["two"]}, + {"id": "two", "argv": ["test"], "deps": ["one"]}, + ], + "profiles": {"quick": ["one"]}, + } + self.write("project.json", json.dumps(config)) + with self.assertRaisesRegex(ValueError, "Cyclic"): + build_stages(self.root, "quick", [], False, self.root / "project.json") + config["checks"][1]["id"] = "one" + self.write("project.json", json.dumps(config)) + with self.assertRaisesRegex(ValueError, "Duplicate"): + build_stages(self.root, "quick", [], False, self.root / "project.json") + + def test_generic_order_prerequisites_inputs_and_reuse_survive_planning(self): + config = { + "schema_version": 1, + "name": "generic", + "repositories": [ + {"name": "one", "path": "one"}, + {"name": "two", "path": "two"}, + ], + "checks": [ + { + "id": "prepare", + "argv": ["prepare"], + "repos": ["two"], + "reuse": "never", + "inputs": {"repos": ["two"]}, + }, + { + "id": "test", + "argv": ["test"], + "repos": ["one"], + "after": ["prepare"], + "reuse": "verified", + "inputs": {"repos": ["one"]}, + }, + {"id": "broad", "argv": ["check"], "repos": ["one"]}, + ], + "profiles": {"quick": ["test", "broad"]}, + } + self.write("project.json", json.dumps(config)) + result = build_stages( + self.root, "quick", ["one"], False, self.root / "project.json" + ) + self.assertEqual([item["id"] for item in result], ["prepare", "test", "broad"]) + self.assertEqual(result[1]["after"], ["prepare"]) + self.assertEqual(result[1]["deps"], []) + self.assertEqual(result[0]["reuse"], "never") + self.assertEqual(result[1]["reuse"], "verified") + self.assertEqual(result[0]["inputs"], {"repos": ["two"]}) + self.assertEqual(result[1]["inputs"], {"repos": ["one"]}) + self.assertNotIn("inputs", result[2]) + + def test_generic_original_types_are_validated_before_coercion(self): + for field, invalid in ( + ("argv", "false"), + ("argv", None), + ("resources", "shared"), + ("cwd", None), + ("cwd", "../outside"), + ("title", 5), + ("timeout_seconds", True), + ): + with self.subTest(field=field, invalid=invalid): + config = { + "schema_version": 1, + "name": "generic", + "repositories": [{"name": "one", "path": "one"}], + "checks": [{"id": "test", "argv": ["test"], field: invalid}], + "profiles": {"quick": ["test"]}, + } + self.write("project.json", json.dumps(config)) + with self.assertRaises(ValueError): + build_stages( + self.root, "quick", [], False, self.root / "project.json" + ) + + def test_ui_coverage_notes_make_excluded_suites_explicit_and_discover_tests_folder( + self, + ): + self.write( + "govoplan-example/webui/package.json", + json.dumps({"scripts": {"test:full-ui": "tsc && node tests/full-ui.js"}}), + ) + self.write( + "govoplan-example/webui/tests/aggregate-report-structure.test.mjs", + "// fixture", + ) + stages = self.plan("ui", ["example"]) + self.assertTrue( + any("aggregate-report-structure" in item["id"] for item in stages) + ) + self.assertTrue( + any("test:full-ui" in note for note in stages[0]["coverage_notes"]) + ) + + def test_docs_reuses_existing_guards_and_only_narrows_plain_labels(self): + args = argparse.Namespace( + workspace_root=self.root, + state_dir=self.root / "state", + project=None, + repo=["example"], + changed=False, + ) + with patch("govoplan_devkit.docs.load_project", return_value=self.project): + stages = build_doc_stages(args) + self.assertEqual(len(stages), 4) + self.assertIn("check-manifest-shapes.py", " ".join(stages[0]["argv"])) + self.assertIn("platform-interface-inventory.py", " ".join(stages[1]["argv"])) + self.assertIn("--strict-declarations", stages[1]["argv"]) + self.assertEqual(stages[3]["argv"][-2:], ["--repo", "govoplan-example"]) + self.assertFalse( + (self.root / "state").exists(), "Planning must not create artifacts" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_devkit_cli.py b/tests/test_devkit_cli.py new file mode 100755 index 0000000..877fa2e --- /dev/null +++ b/tests/test_devkit_cli.py @@ -0,0 +1,158 @@ +"""Public CLI contracts and an executable portable-project smoke fixture.""" + +import json +from pathlib import Path +import subprocess +import sys +from unittest.mock import patch + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools/devkit")) +from govoplan_devkit import cli, doctor, runner +from govoplan_devkit.common import META_ROOT + + +@pytest.mark.parametrize( + "arguments", + [["--json", "commands"], ["commands", "--json"], ["commands", "--format", "json"]], +) +def test_global_output_flags_work_on_either_side_of_command(arguments, capsys): + assert cli.main(arguments) == 0 + output = json.loads(capsys.readouterr().out) + names = {item["command"] for item in output["commands"]} + assert {"context", "check", "doctor", "docs", "issues", "release", "git"} <= names + + +@pytest.mark.parametrize( + "command", + [ + "context", + "doctor", + "check", + "resume", + "recover", + "docs", + "review", + "issues", + "release", + "git", + ], +) +def test_command_help_does_not_require_live_services(command, capsys): + with pytest.raises(SystemExit) as stopped: + cli.main([command, "--help"]) + assert stopped.value.code == 0 + assert "usage:" in capsys.readouterr().out + + +def test_portable_project_executes_registered_commands_and_reads_receipt( + tmp_path, capsys, monkeypatch +): + monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "state")) + repo = tmp_path / "example" + repo.mkdir() + subprocess.run(["git", "init", "-q", str(repo)], check=True) + subprocess.run( + [ + "git", + "-C", + str(repo), + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "commit", + "--allow-empty", + "-qm", + "fixture", + ], + check=True, + ) + project = tmp_path / "project.json" + project.write_text( + json.dumps( + { + "schema_version": 1, + "name": "Portable", + "repositories": [{"name": "example", "path": "example"}], + "checks": [ + { + "id": "test", + "argv": ["{python}", "-c", "print('portable ok')"], + "cwd": "example", + } + ], + "profiles": {"quick": ["test"]}, + } + ) + ) + common = ["--workspace-root", str(tmp_path), "--project", str(project), "--json"] + with patch.object(runner, "environment_fingerprint", return_value="fixture"): + assert cli.main(common + ["check", "--profile", "quick"]) == 0 + result = json.loads(capsys.readouterr().out) + assert result["status"] == "passed" + assert cli.main(["status", result["run_id"], *common]) == 0 + status = json.loads(capsys.readouterr().out) + assert status["snapshot_verified"] is True + assert cli.main(["logs", result["run_id"], "--stage", "test", *common]) == 0 + assert "portable ok" in capsys.readouterr().out + + +def test_portable_example_matches_published_schema(): + import jsonschema + + jsonschema.validate( + json.loads((META_ROOT / "tools/devkit/examples/project.json").read_text()), + json.loads((META_ROOT / "tools/devkit/project.schema.json").read_text()), + ) + + +def test_malformed_project_is_a_controlled_json_error(tmp_path, capsys): + project = tmp_path / "bad.json" + project.write_text('{"schema_version":true,"repositories":[]}') + assert ( + cli.main( + [ + "context", + "--workspace-root", + str(tmp_path), + "--project", + str(project), + "--json", + ] + ) + == 2 + ) + assert json.loads(capsys.readouterr().out)["status"] == "error" + + +def test_doctor_is_read_only_and_preserves_dependency_warnings(tmp_path, capsys): + project = tmp_path / "project.json" + project.write_text( + json.dumps( + {"schema_version": 1, "repositories": [{"name": "example", "path": "."}]} + ) + ) + (tmp_path / "package.json").write_text("{}") + before = set(tmp_path.iterdir()) + with ( + patch.object(doctor, "tool_version", return_value="fixture"), + patch.object(doctor, "inspect_repository", return_value={"errors": []}), + ): + assert ( + cli.main( + [ + "doctor", + "--workspace-root", + str(tmp_path), + "--project", + str(project), + "--json", + ] + ) + == 0 + ) + output = json.loads(capsys.readouterr().out) + assert any(item["status"] == "warning" for item in output["checks"]) + assert set(tmp_path.iterdir()) == before diff --git a/tests/test_devkit_coverage.py b/tests/test_devkit_coverage.py new file mode 100755 index 0000000..98b0037 --- /dev/null +++ b/tests/test_devkit_coverage.py @@ -0,0 +1,339 @@ +"""Coverage inventory is explicit intent, never execution or guessed completion.""" + +import json +from pathlib import Path +import re +import shutil +import sys +from unittest.mock import patch + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools/devkit")) +from govoplan_devkit.catalog import build_coverage, build_stages, module_ui_stages # noqa: E402 +from govoplan_devkit.coverage import canonical_invocations # noqa: E402 +from govoplan_devkit.package_tests import ( # noqa: E402 + CORE_COMPONENT_SUITES, + declared_tests, + read_package, +) +from govoplan_devkit.workspace import Project, Repository # noqa: E402 + + +@pytest.fixture +def fixture(tmp_path): + core = Repository("govoplan-core", tmp_path / "govoplan-core", ("core",)) + meta = Repository("govoplan", tmp_path / "govoplan", ("meta",)) + module = Repository("govoplan-example", tmp_path / "govoplan-example", ("example",)) + for repo in (core, meta, module): + (repo.path / "webui/scripts").mkdir(parents=True) + (repo.path / "webui/tests").mkdir() + scripts = { + "test:components": "node scripts/run-component-tests.mjs", + **{ + f"test:{name}": f"node scripts/run-component-tests.mjs {name}" + for name in CORE_COMPONENT_SUITES + }, + } + (core.path / "webui/package.json").write_text(json.dumps({"scripts": scripts})) + (core.path / "webui/scripts/run-component-tests.mjs").write_text("// fixture") + (module.path / "webui/package.json").write_text( + json.dumps( + { + "scripts": { + "test:safe": "node --test tests/source.test.mjs", + "test:compound": "tsc && node test.js", + "test:bad-quote": "node 'broken", + } + } + ) + ) + (module.path / "webui/tests/source.test.mjs").write_text("// fixture") + script = meta.path / "tools/checks/check-focused.sh" + script.parent.mkdir(parents=True) + phase_metadata = ROOT / "tools/checks/focused-phases.json" + shutil.copyfile(phase_metadata, script.with_name("focused-phases.json")) + bodies = { + "core-ui": 'cd "$ROOT/webui"\n"$NPM" run test:components -- layout-primitives page-layout data-grid-actions mail-components\n', + "module-ui": 'cd "$WORKSPACE_ROOT/govoplan-example/webui"\n"$NPM" run test:compound\n', + } + script.write_text( + "\n".join( + f"focused_phase_{phase['id'].replace('-', '_')}() {{\n# devkit-phase: {phase['id']} begin\n" + + bodies.get(phase["id"], 'cd "$ROOT"\n') + + f"# devkit-phase: {phase['id']} end\n}}\n" + for phase in json.loads(phase_metadata.read_text())["phases"] + ) + ) + project = Project("Fixture", (meta, core, module), {}) + with ( + patch("govoplan_devkit.workspace.load_project", return_value=project), + patch("govoplan_devkit.coverage.load_project", return_value=project), + ): + yield tmp_path, core, module, project + + +def rows(coverage, repo="govoplan-core"): + return {row["name"]: row for row in coverage["suites"] if row["repo"] == repo} + + +def test_core_aliases_are_explicitly_excluded_in_quick_and_covered_by_one_ui_stage( + fixture, +): + root, _, _, _ = fixture + quick = rows(build_coverage(root, "quick", [], False)) + ui = rows(build_coverage(root, "ui", [], False)) + for suite in CORE_COMPONENT_SUITES: + assert quick["test:" + suite]["disposition"] == "excluded" + assert "quick" in quick["test:" + suite]["reason"] + assert ui["test:" + suite]["disposition"] == "covered_elsewhere" + assert ui["test:" + suite]["covering_stage"] == "core.component-batch" + assert ui["test:components"]["covered_components"] == list(CORE_COMPONENT_SUITES) + + +def test_full_reports_only_four_of_sixteen_components_and_exact_shell_suite(fixture): + root, _, _, _ = fixture + result = build_coverage(root, "full", ["example"], False) + core = rows(result) + aliases = [core["test:" + suite] for suite in CORE_COMPONENT_SUITES] + assert sum(item["disposition"] == "covered_elsewhere" for item in aliases) == 4 + assert sum(item["disposition"] == "excluded" for item in aliases) == 12 + assert len(core["test:components"]["covered_components"]) == 4 + assert "4/16" in core["test:components"]["reason"] + module = rows(result, "govoplan-example") + assert module["test:compound"]["disposition"] == "planned" + assert module["test:safe"]["disposition"] == "excluded" + assert result["stages"] == [ + "focused." + phase["id"] + for phase in json.loads( + (ROOT / "tools/checks/focused-phases.json").read_text() + )["phases"] + ] + assert core["test:components"]["covering_stage"] == "focused.core-ui" + assert module["test:compound"]["covering_stage"] == "focused.module-ui" + + +def test_prebuilt_plan_avoids_replanning(fixture): + root, _, _, _ = fixture + stages = build_stages(root, "quick", [], False) + with patch( + "govoplan_devkit.catalog.build_stages", + side_effect=AssertionError("must not replan"), + ): + result = build_coverage(root, "quick", [], False, stages=stages) + assert rows(result, "govoplan-example")["test:safe"]["disposition"] == "planned" + assert sum(result["counts"].values()) == result["suite_count"] + assert all(item["reason"] for item in result["suites"]) + + +def test_missing_or_spoofed_phase_stage_does_not_grant_component_coverage(fixture): + root, _, _, _ = fixture + stages = build_stages(root, "full", [], False) + without_core = [item for item in stages if item["id"] != "focused.core-ui"] + result = build_coverage(root, "full", [], False, stages=without_core) + assert rows(result)["test:components"]["covered_components"] == [] + assert ( + rows(result, "govoplan-example")["test:compound"]["covering_stage"] + == "focused.module-ui" + ) + next(item for item in stages if item["id"] == "focused.core-ui")["argv"] = ["true"] + spoofed = build_coverage(root, "full", [], False, stages=stages) + assert rows(spoofed)["test:components"]["covered_components"] == [] + + +@pytest.mark.parametrize("wrapper", ["heredoc", "function", "conditional"]) +def test_lookalike_phase_wrappers_outside_top_level_do_not_grant_coverage( + fixture, wrapper +): + root, core, _, project = fixture + meta = next(repo.path for repo in project.repositories if repo.name == "govoplan") + path = meta / "tools/checks/check-focused.sh" + fake = 'focused_phase_core_ui() {\n# devkit-phase: core-ui begin\ncd "$ROOT/webui"\n"$NPM" run test:spoof\n# devkit-phase: core-ui end\n}\n' + prefix = { + "heredoc": "cat <<'BODY'\n" + fake + "BODY\n", + "function": "unused() {\n" + fake + "}\n", + "conditional": "if false; then\n" + fake + "fi\n", + }[wrapper] + path.write_text(prefix + path.read_text()) + result = canonical_invocations(root, meta, core.path) + assert result["notes"] == [] + assert [item["name"] for item in result["npm"]] == [ + "test:components", + "test:compound", + ] + + +@pytest.mark.parametrize("damage", ["missing", "duplicate", "bad-end"]) +def test_invalid_marked_phase_bodies_do_not_infer_coverage(fixture, damage): + root, core, _, project = fixture + meta = next(repo.path for repo in project.repositories if repo.name == "govoplan") + path = meta / "tools/checks/check-focused.sh" + text = path.read_text() + if damage == "missing": + text = text.replace("focused_phase_core_ui()", "unregistered_core_ui()") + elif damage == "duplicate": + text += text + else: + text = text.replace( + "# devkit-phase: core-ui end", "# devkit-phase: another end" + ) + path.write_text(text) + result = canonical_invocations(root, meta, core.path) + assert result["npm"] == [] and result["node"] == [] + assert "no phase coverage inferred" in result["notes"][0] + + +@pytest.mark.parametrize( + "repo_name,name,command", + [ + ("govoplan-example", "test:components", "node scripts/run-component-tests.mjs"), + ( + "govoplan-core", + "test:dialog-focus", + "node scripts/run-component-tests.mjs dialog-focus && npm run dev", + ), + ( + "govoplan-core", + "test:unrecognized", + "node scripts/run-component-tests.mjs dialog-focus", + ), + ], +) +def test_component_alias_exemption_is_exact_and_core_only( + tmp_path, repo_name, name, command +): + repo = Repository(repo_name, tmp_path / repo_name) + package = repo.path / "webui/package.json" + package.parent.mkdir(parents=True) + package.write_text(json.dumps({"scripts": {name: command}})) + declared = declared_tests(repo, package) + assert declared[0]["component_suite"] is None + assert declared[0]["_argv"] is None + assert module_ui_stages(repo, reason="fixture") == [] + + +@pytest.mark.parametrize( + "body", + [ + "[]", + "null", + '{"scripts":[]}', + '{"scripts":{"test:bad":42}}', + '{"scripts":{"test:one":"node a","test:one":"node b"}}', + "[" * 2000 + "]" * 2000, + ], +) +def test_malformed_package_metadata_is_a_controlled_error(tmp_path, body): + package = tmp_path / "package.json" + package.write_text(body) + with pytest.raises(ValueError): + read_package(package) + + +def test_oversize_package_and_symlinked_test_are_not_discovered(tmp_path): + repo = Repository("example", tmp_path) + webui = tmp_path / "webui" + (webui / "tests").mkdir(parents=True) + package = webui / "package.json" + package.write_text(" " * (1024 * 1024 + 1)) + with pytest.raises(ValueError): + read_package(package) + package.write_text( + json.dumps({"scripts": {"test:escape": "node tests/escape.mjs"}}) + ) + (webui / "tests/escape.mjs").symlink_to(tmp_path / "outside.mjs") + (tmp_path / "outside.mjs").write_text("// fixture") + assert module_ui_stages(repo, reason="fixture") == [] + + +def test_unparseable_and_sensitive_commands_do_not_leak_into_coverage(fixture): + root, _, module, _ = fixture + package = module.path / "webui/package.json" + package.write_text( + json.dumps( + { + "scripts": { + "test:secret": "node tests/source.test.mjs --token unknown-private-value", + "test:broken": "node 'unknown-other-secret", + } + } + ) + ) + result = build_coverage(root, "quick", [], False) + encoded = json.dumps(result) + assert ( + "unknown-private-value" not in encoded and "unknown-other-secret" not in encoded + ) + assert "_argv" not in encoded and '"command"' not in encoded + assert all( + item["disposition"] == "unsupported" + for item in rows(result, "govoplan-example").values() + ) + + +def test_custom_check_coverage_redacts_separate_token_and_includes_unselected(tmp_path): + project = tmp_path / "project.json" + project.write_text( + json.dumps( + { + "schema_version": 1, + "repositories": [{"name": "app", "path": "."}], + "checks": [ + { + "id": "one", + "argv": ["check", "--token", "unknown-private-value"], + }, + {"id": "two", "argv": ["true"]}, + ], + "profiles": {"quick": ["one"]}, + } + ) + ) + result = build_coverage(tmp_path, "quick", [], False, project) + assert "unknown-private-value" not in json.dumps(result) + assert result["counts"]["planned"] == 1 and result["counts"]["excluded"] == 1 + + +def test_canonical_parser_does_not_credit_comments_heredocs_conditionals_or_chains( + tmp_path, +): + meta, core = tmp_path / "govoplan", tmp_path / "govoplan-core" + path = meta / "tools/checks/check-focused.sh" + path.parent.mkdir(parents=True) + path.write_text( + 'cd "$ROOT/webui"\n# "$NPM" run test:comment\n"$PYTHON" - <<\'PY\'\n"$NPM" run test:heredoc\nPY\nif false; then\n"$NPM" run test:conditional\nfi\n"$NPM" run test:compound && true\n"$NPM" run test:real\n' + ) + result = canonical_invocations(tmp_path, meta, core) + assert [item["name"] for item in result["npm"]] == ["test:real"] + + +def test_real_canonical_gate_has_four_explicit_component_suites(): + result = canonical_invocations(ROOT.parent, ROOT, ROOT.parent / "govoplan-core") + calls = [item for item in result["npm"] if item["name"] == "test:components"] + assert len(calls) == 1 + assert calls[0]["args"] == [ + "layout-primitives", + "page-layout", + "data-grid-actions", + "mail-components", + ] + assert calls[0]["phase"] == "core-ui" + for name, phase in ( + ("test:module-permutations", "module-builds"), + ("test:conformance", "browser"), + ): + matching = [item for item in result["npm"] if item["name"] == name] + assert len(matching) == 1 and matching[0]["phase"] == phase + + +def test_known_core_component_aliases_match_the_owned_runner_registry(): + runner = ( + ROOT.parent / "govoplan-core/webui/scripts/run-component-tests.mjs" + ).read_text() + block = runner.split("export const componentSuites = Object.freeze({", 1)[1].split( + "});", 1 + )[0] + assert set(re.findall(r'^ "([a-z0-9-]+)":', block, re.M)) == set( + CORE_COMPONENT_SUITES + ) diff --git a/tests/test_devkit_doctor.py b/tests/test_devkit_doctor.py new file mode 100755 index 0000000..48bde47 --- /dev/null +++ b/tests/test_devkit_doctor.py @@ -0,0 +1,164 @@ +"""Portable preflight inference uses fixtures only and never installs or starts tools.""" + +from argparse import Namespace +from pathlib import Path +import sys +from unittest.mock import patch + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools/devkit")) +from govoplan_devkit import doctor +from govoplan_devkit.workspace import Project, Repository + + +def project(tmp_path, checks, *, tools=None, profiles=None): + return Project( + "Fixture", + (Repository("app", tmp_path / "app"), Repository("other", tmp_path / "other")), + { + "checks": checks, + "profiles": profiles + if profiles is not None + else {"quick": [item["id"] for item in checks]}, + "tools": tools or {}, + }, + ) + + +def check(identity, executable, *, repos=None, deps=None): + return { + "id": identity, + "argv": [executable, "--version"], + "cwd": ".", + "repos": repos or [], + "deps": deps or [], + } + + +def diagnose(tmp_path, configured, *, profile=None, repos=None, versions=None): + args = Namespace( + workspace_root=tmp_path, + project=tmp_path / "not-read.json", + repo=repos or [], + profile=profile, + ) + tools = {"python": sys.executable, "node": "fixture-node", "npm": "fixture-npm"} + version_map = versions or { + sys.executable: "Python 3", + "fixture-node": "unavailable", + "fixture-npm": "unavailable", + } + with ( + patch.object(doctor, "load_project", return_value=configured), + patch.object(doctor, "resolve_tools", return_value=tools), + patch.object( + doctor, + "tool_version", + side_effect=lambda executable, _env: version_map[executable], + ) as probe, + patch.object(doctor, "inspect_repository", return_value={"errors": []}), + ): + result = doctor.diagnose(args) + return result, probe + + +def test_python_only_portable_project_does_not_probe_or_block_on_unused_node(tmp_path): + configured = project(tmp_path, [check("python-test", "{python}")]) + result, probe = diagnose(tmp_path, configured) + assert result["_exit_code"] == 0 + assert result["required_tools"] == ["python"] + assert probe.call_count == 1 + assert { + item["id"] for item in result["checks"] if item["status"] == "not_required" + } == {"node", "npm"} + + +def test_selected_profile_excludes_other_profile_tool_requirements(tmp_path): + configured = project( + tmp_path, + [check("py", "{python}"), check("js", "{npm}")], + profiles={"quick": ["py"], "ui": ["js"]}, + ) + result, _ = diagnose(tmp_path, configured, profile="quick") + assert result["required_tools"] == ["python"] + result, _ = diagnose(tmp_path, configured) + assert result["required_tools"] == ["node", "npm", "python"] + assert result["_exit_code"] == 1 + + +def test_selected_repository_includes_dependency_tool_requirements(tmp_path): + configured = project( + tmp_path, + [ + check("build", "{npm}", repos=["other"]), + check("test", "{python}", repos=["app"], deps=["build"]), + ], + ) + result, _ = diagnose(tmp_path, configured, repos=["app"]) + assert result["required_tools"] == ["node", "npm", "python"] + + +def test_unselected_repository_does_not_require_its_tool(tmp_path): + configured = project( + tmp_path, + [check("js", "{npm}", repos=["other"]), check("py", "{python}", repos=["app"])], + ) + result, _ = diagnose(tmp_path, configured, repos=["app"]) + assert result["required_tools"] == ["python"] + + +def test_explicit_tool_configuration_declares_indirect_script_dependency(tmp_path): + configured = project(tmp_path, [check("shell", "sh")], tools={"npm": "fixture-npm"}) + result, _ = diagnose(tmp_path, configured) + assert result["required_tools"] == ["node", "npm", "python"] + assert result["_exit_code"] == 1 + + +@pytest.mark.parametrize( + "executable,expected", + [ + ("/opt/node/bin/node", ["node", "python"]), + ("npm", ["node", "npm", "python"]), + ("npx", ["node", "npm", "python"]), + ], +) +def test_direct_tool_names_are_inferred(tmp_path, executable, expected): + result, _ = diagnose(tmp_path, project(tmp_path, [check("test", executable)])) + assert result["required_tools"] == expected + + +def test_context_only_project_needs_no_node_and_makes_no_files(tmp_path): + before = set(tmp_path.iterdir()) + result, _ = diagnose(tmp_path, project(tmp_path, [], profiles={})) + assert result["required_tools"] == ["python"] + assert set(tmp_path.iterdir()) == before + + +def test_missing_profile_is_not_silently_widened(tmp_path): + with pytest.raises(ValueError, match="does not declare profile"): + diagnose(tmp_path, project(tmp_path, []), profile="ui") + + +def test_native_govoplan_still_requires_all_three_tools(tmp_path): + configured = project(tmp_path, []) + args = Namespace(workspace_root=tmp_path, project=None, repo=[], profile=None) + with ( + patch.object(doctor, "load_project", return_value=configured), + patch.object( + doctor, + "resolve_tools", + return_value={"python": sys.executable, "node": "node", "npm": "npm"}, + ), + patch.object( + doctor, + "tool_version", + side_effect=lambda executable, _env: ( + "Python" if executable == sys.executable else "unavailable" + ), + ), + patch.object(doctor, "inspect_repository", return_value={"errors": []}), + ): + result = doctor.diagnose(args) + assert result["required_tools"] == ["node", "npm", "python"] + assert result["_exit_code"] == 1 diff --git a/tests/test_devkit_environment.py b/tests/test_devkit_environment.py new file mode 100755 index 0000000..e32e8a3 --- /dev/null +++ b/tests/test_devkit_environment.py @@ -0,0 +1,378 @@ +"""Environment probes read bounded stable files; all fixtures are local.""" + +import hashlib +import os +from pathlib import Path +import sys +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools/devkit")) +from govoplan_devkit import environment +from govoplan_devkit.common import digest +from govoplan_devkit.workspace import Project, Repository + + +def mutate_during_read(monkeypatch, callback): + original = hashlib.sha256 + mutated = False + + class MutatingHasher: + def __init__(self): + self.hasher = original() + + def update(self, chunk): + nonlocal mutated + self.hasher.update(chunk) + if not mutated: + mutated = True + callback() + + def hexdigest(self): + return self.hasher.hexdigest() + + monkeypatch.setattr(environment.hashlib, "sha256", MutatingHasher) + + +@pytest.mark.parametrize("content", [b"", b"stable", b"x" * (1024 * 1024 + 3)]) +def test_file_hash_keeps_existing_digest_and_exact_size_boundary(tmp_path, content): + path = tmp_path / "input" + path.write_bytes(content) + assert ( + environment._environment_file_hash(path, len(content)) + == hashlib.sha256(content).hexdigest() + ) + + +def test_missing_optional_file_remains_optional_without_a_persistent_cache(tmp_path): + path = tmp_path / "input" + assert environment._environment_file_hash(path, 10) is None + path.write_bytes(b"first") + first = environment._environment_file_hash(path, 10) + path.write_bytes(b"other") + assert environment._environment_file_hash(path, 10) != first + + +@pytest.mark.parametrize("kind", ["fifo", "directory", "oversized", "dangling"]) +def test_present_unsafe_input_is_rejected_without_blocking(tmp_path, kind): + path = tmp_path / "input" + if kind == "fifo": + os.mkfifo(path) + elif kind == "directory": + path.mkdir() + elif kind == "oversized": + path.write_bytes(b"too large") + else: + path.symlink_to(tmp_path / "missing") + with pytest.raises(ValueError, match="Environment input"): + environment._environment_file_hash(path, 2) + + +def test_stable_venv_executable_symlink_keeps_original_path_and_identity(tmp_path): + target = tmp_path / "real-python" + target.write_bytes(b"fixture binary") + executable = tmp_path / "venv" / "bin" / "python" + executable.parent.mkdir(parents=True) + executable.symlink_to(target) + repo = tmp_path / "repo" + metadata = repo / "node_modules" / ".package-lock.json" + metadata.parent.mkdir(parents=True) + metadata.write_bytes(b'{"fixture":true}') + project = Project("Fixture", (Repository("repo", repo),), {}) + tools = {"python": str(executable)} + env = {"PATH": "fixture", "PWD": "ignored"} + distributions = b'[["fixture", "1"]]\n' + with ( + patch.object(environment, "tool_version", return_value="fixture-version"), + patch.object( + environment, + "require_capture", + return_value=SimpleNamespace(returncode=0, stdout=distributions), + ) as capture, + ): + actual = environment.environment_fingerprint(tmp_path, project, tools, env) + assert actual == digest( + { + "environment": {"PATH": "fixture"}, + "tools": { + "python": { + "path": str(executable), + "version": "fixture-version", + "sha256": hashlib.sha256(target.read_bytes()).hexdigest(), + } + }, + "installed": { + str(metadata): hashlib.sha256(metadata.read_bytes()).hexdigest(), + "python_distributions": hashlib.sha256(distributions).hexdigest(), + }, + } + ) + assert capture.call_args.args[0][0] == str(executable) + + +def test_symlinked_package_directory_is_allowed_when_stable(tmp_path): + actual = tmp_path / "packages" + actual.mkdir() + (actual / ".package-lock.json").write_bytes(b"fixture") + link = tmp_path / "node_modules" + link.symlink_to(actual, target_is_directory=True) + assert environment._environment_file_hash(link / ".package-lock.json", 10) + + +def test_fifo_replacement_between_inspection_and_open_does_not_block( + tmp_path, monkeypatch +): + path = tmp_path / "input" + path.write_bytes(b"fixture") + real_open = os.open + + def replace_before_open(target, flags): + path.unlink() + os.mkfifo(path) + assert flags & os.O_NONBLOCK + return real_open(target, flags) + + monkeypatch.setattr(environment.os, "open", replace_before_open) + with pytest.raises(ValueError, match="bounded regular"): + environment._environment_file_hash(path, 10) + + +def test_growth_between_inspection_and_open_cannot_bypass_size_bound( + tmp_path, monkeypatch +): + path = tmp_path / "input" + path.write_bytes(b"a") + real_open = os.open + + def grow_before_open(target, flags): + path.write_bytes(b"x" * 11) + return real_open(target, flags) + + monkeypatch.setattr(environment.os, "open", grow_before_open) + with pytest.raises(ValueError, match="bounded regular"): + environment._environment_file_hash(path, 10) + + +def test_growth_during_read_cannot_bypass_size_bound(tmp_path, monkeypatch): + path = tmp_path / "input" + path.write_bytes(b"a") + mutate_during_read(monkeypatch, lambda: path.write_bytes(b"x" * 11)) + with pytest.raises(ValueError, match="grew beyond"): + environment._environment_file_hash(path, 10) + + +def test_same_size_edit_with_restored_mtime_is_not_a_stable_identity( + tmp_path, monkeypatch +): + path = tmp_path / "input" + path.write_bytes(b"first") + metadata = path.stat() + + def mutate(): + path.write_bytes(b"other") + os.utime(path, ns=(metadata.st_atime_ns, metadata.st_mtime_ns)) + + mutate_during_read(monkeypatch, mutate) + with pytest.raises(ValueError, match="changed during"): + environment._environment_file_hash(path, 10) + + +def test_replacement_with_identical_bytes_is_not_a_stable_identity( + tmp_path, monkeypatch +): + path = tmp_path / "input" + path.write_bytes(b"fixture") + replacement = tmp_path / "replacement" + replacement.write_bytes(b"fixture") + mutate_during_read(monkeypatch, lambda: replacement.replace(path)) + with pytest.raises(ValueError, match="changed during"): + environment._environment_file_hash(path, 10) + + +def test_symlink_retarget_with_identical_bytes_is_not_a_stable_identity( + tmp_path, monkeypatch +): + first, second, link = (tmp_path / name for name in ("first", "second", "python")) + first.write_bytes(b"fixture") + second.write_bytes(b"fixture") + link.symlink_to(first) + + def retarget(): + link.unlink() + link.symlink_to(second) + + mutate_during_read(monkeypatch, retarget) + with pytest.raises(ValueError, match="changed during"): + environment._environment_file_hash(link, 10) + + +def test_source_disappearing_during_read_fails_closed(tmp_path, monkeypatch): + path = tmp_path / "input" + path.write_bytes(b"fixture") + mutate_during_read(monkeypatch, path.unlink) + with pytest.raises(ValueError, match="Environment input"): + environment._environment_file_hash(path, 10) + + +def test_parent_symlink_retarget_is_rejected_even_for_the_same_target_inode( + tmp_path, monkeypatch +): + first, second, link = (tmp_path / name for name in ("first", "second", "bin")) + first.mkdir() + second.mkdir() + (first / "python").write_bytes(b"fixture") + os.link(first / "python", second / "python") + link.symlink_to(first, target_is_directory=True) + + def retarget(): + link.unlink() + link.symlink_to(second, target_is_directory=True) + + mutate_during_read(monkeypatch, retarget) + with pytest.raises(ValueError, match="changed during"): + environment._environment_file_hash(link / "python", 10) + + +def test_invalid_executable_is_rejected_before_any_version_process(tmp_path): + executable = tmp_path / "python" + os.mkfifo(executable) + project = Project("Fixture", (), {}) + with patch.object(environment, "tool_version") as version: + with pytest.raises(ValueError, match="bounded regular"): + environment.environment_fingerprint( + tmp_path, project, {"python": str(executable)}, {} + ) + version.assert_not_called() + + +def discovery_project(root, *, path="govoplan-backend"): + repo = root / path + (repo / "src").mkdir(parents=True) + return Project( + "GovOPlaN", + (Repository("govoplan-backend", repo),), + {"organization": "GovOPlaN"}, + ) + + +def test_native_shape_ignores_build_files_and_directory_timestamps(tmp_path): + project = discovery_project(tmp_path) + repo = project.repositories[0].path + (repo / "webui").mkdir() + before = environment._native_discovery_fingerprint(tmp_path, project) + for directory in (repo, repo / "src", repo / "webui"): + (directory / "temporary-build-output").write_text("changed") + (directory / "temporary-build-directory").mkdir() + assert environment._native_discovery_fingerprint(tmp_path, project) == before + (repo / "webui" / "temporary-build-output").unlink() + assert environment._native_discovery_fingerprint(tmp_path, project) == before + + +def test_native_shape_detects_unknown_sibling_and_source_addition_removal(tmp_path): + project = discovery_project(tmp_path) + baseline = environment._native_discovery_fingerprint(tmp_path, project) + sibling = tmp_path / "govoplan-unregistered" + sibling.mkdir() + empty = environment._native_discovery_fingerprint(tmp_path, project) + assert empty != baseline + for name in ("src", "webui"): + (sibling / name).mkdir() + assert environment._native_discovery_fingerprint(tmp_path, project) != empty + (sibling / name).rmdir() + assert environment._native_discovery_fingerprint(tmp_path, project) == empty + sibling.rmdir() + assert environment._native_discovery_fingerprint(tmp_path, project) == baseline + + +@pytest.mark.parametrize("repo_path", ["govoplan-backend", "nonstandard-layout"]) +def test_native_shape_detects_registered_backend_gaining_webui(tmp_path, repo_path): + project = discovery_project(tmp_path, path=repo_path) + baseline = environment._native_discovery_fingerprint(tmp_path, project) + webui = project.repositories[0].path / "webui" + webui.mkdir() + assert environment._native_discovery_fingerprint(tmp_path, project) != baseline + webui.rmdir() + assert environment._native_discovery_fingerprint(tmp_path, project) == baseline + + +def test_native_shape_binds_registered_ownership_names(tmp_path): + project = discovery_project(tmp_path, path="nonstandard-layout") + renamed = Project( + project.name, + (Repository("govoplan-renamed", project.repositories[0].path),), + project.config, + ) + assert environment._native_discovery_fingerprint( + tmp_path, project + ) != environment._native_discovery_fingerprint(tmp_path, renamed) + + +def test_native_shape_detects_source_link_retarget_and_dangling_target(tmp_path): + project = discovery_project(tmp_path) + first, second = tmp_path / "first", tmp_path / "second" + first.mkdir() + second.mkdir() + link = project.repositories[0].path / "webui" + link.symlink_to(first, target_is_directory=True) + baseline = environment._native_discovery_fingerprint(tmp_path, project) + link.unlink() + link.symlink_to(second, target_is_directory=True) + changed = environment._native_discovery_fingerprint(tmp_path, project) + assert changed != baseline + second.rmdir() + assert environment._native_discovery_fingerprint(tmp_path, project) != changed + + +def test_native_shape_encodes_non_directory_source_entries(tmp_path): + project = discovery_project(tmp_path) + webui = project.repositories[0].path / "webui" + baseline = environment._native_discovery_fingerprint(tmp_path, project) + webui.write_text("not a directory") + file_shape = environment._native_discovery_fingerprint(tmp_path, project) + assert file_shape != baseline + webui.unlink() + webui.mkdir() + assert environment._native_discovery_fingerprint(tmp_path, project) != file_shape + + +def test_native_shape_audit_is_bounded_including_nonmatching_children( + tmp_path, monkeypatch +): + project = discovery_project(tmp_path) + (tmp_path / "unrelated-one").mkdir() + (tmp_path / "unrelated-two").mkdir() + monkeypatch.setattr(environment, "MAX_DISCOVERY_CHILDREN", 2) + with pytest.raises(ValueError, match="bounded ownership"): + environment._native_discovery_fingerprint(tmp_path, project) + + +def test_native_shape_resolution_loop_fails_closed(tmp_path): + project = discovery_project(tmp_path) + link = project.repositories[0].path / "webui" + link.symlink_to(link) + with pytest.raises(ValueError, match="discovery cannot be resolved"): + environment._native_discovery_fingerprint(tmp_path, project) + + +@pytest.mark.parametrize("portable", [False, True]) +def test_only_native_environment_identity_binds_discovery_shape(tmp_path, portable): + project = discovery_project(tmp_path) + if portable: + project.config["schema_version"] = 1 + executable = tmp_path / "fixture-python" + executable.write_text("not executed") + tools = {"python": str(executable)} + with ( + patch.object(environment, "tool_version", return_value="fixture"), + patch.object( + environment, + "require_capture", + return_value=SimpleNamespace(returncode=0, stdout=b"[]"), + ), + ): + baseline = environment.environment_fingerprint(tmp_path, project, tools, {}) + (tmp_path / "govoplan-new" / "src").mkdir(parents=True) + changed = environment.environment_fingerprint(tmp_path, project, tools, {}) + assert (baseline == changed) is portable diff --git a/tests/test_devkit_incremental.py b/tests/test_devkit_incremental.py new file mode 100755 index 0000000..0ebc594 --- /dev/null +++ b/tests/test_devkit_incremental.py @@ -0,0 +1,601 @@ +"""Incremental checkpoints use isolated local repositories, never product or remote state.""" + +from argparse import Namespace +import argparse +import json +from pathlib import Path +import subprocess +import sys +import threading +import time +from unittest.mock import patch + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools/devkit")) +from govoplan_devkit import runner +from govoplan_devkit.checkpoints import Checkpoints +from govoplan_devkit.common import atomic_json, read_json, resource_lock, state_root + + +@pytest.fixture +def workspace(tmp_path, monkeypatch): + monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "xdg")) + root = tmp_path / "workspace" + repos = {} + for name in ("alpha", "beta"): + repo = root / name + repo.mkdir(parents=True) + subprocess.run(["git", "init", "-q", str(repo)], check=True) + (repo / "source.txt").write_text(name + " original\n") + subprocess.run(["git", "-C", str(repo), "add", "source.txt"], check=True) + subprocess.run( + [ + "git", + "-C", + str(repo), + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "commit", + "-qm", + "fixture", + ], + check=True, + ) + repos[name] = repo + config = tmp_path / "project.json" + config.write_text( + json.dumps( + { + "schema_version": 1, + "name": "Incremental fixture", + "repositories": [{"name": name, "path": name} for name in repos], + "checks": [], + "profiles": {}, + } + ) + ) + traces = tmp_path / "traces" + traces.mkdir() + args = Namespace( + workspace_root=root, + project=config, + state_dir=tmp_path / "state", + dry_run=False, + jobs=2, + profile="quick", + resume=None, + repo=[], + changed=False, + ) + return args, repos, traces + + +def stage(workspace, identity, repo="alpha", *, inputs=True, body="", **extra): + _, repos, traces = workspace + counter = traces / identity + code = ( + "from pathlib import Path; " + f"counter=Path({str(counter)!r}); " + "counter.write_text(str(int(counter.read_text())+1 if counter.exists() else 1)); " + f"print({identity!r},flush=True); " + body + ) + result = { + "id": identity, + "title": identity, + "argv": [sys.executable, "-c", code], + "cwd": str(repos[repo]), + "timeout_seconds": 5, + **extra, + } + if inputs: + result["inputs"] = {"repos": [repo]} + return result + + +def run(workspace, stages, *, environment="e" * 64): + args, _, _ = workspace + with patch.object(runner, "environment_fingerprint", return_value=environment): + return runner.run_checks(args, stages) + + +def count(workspace, identity): + path = workspace[2] / identity + return int(path.read_text()) if path.exists() else 0 + + +def stages_by_id(result): + return {item["id"]: item for item in result["stages"]} + + +def test_checkpoint_probes_and_durable_persistence_hold_stage_resource_lock(workspace): + args, _, _ = workspace + locks = state_root(args.workspace_root) / "resource-locks" + original_identity, original_write = Checkpoints.identity, runner.atomic_json + probes, persisted = [], [] + + def assert_held(): + with pytest.raises(RuntimeError, match="busy"): + with resource_lock(locks, "checkpoint-fixture"): + pass + + def identity(self, selected): + assert_held() + probes.append(selected["id"]) + return original_identity(self, selected) + + def write(path, payload): + original_write(path, payload) + if ( + payload.get("phase") == "checking" + and payload["stages"][0].get("checkpoint_verified") is True + and not persisted + ): + assert_held() + assert read_json(path)["stages"][0]["checkpoint_verified"] is True + persisted.append(path) + + with ( + patch.object(Checkpoints, "identity", identity), + patch.object(runner, "atomic_json", side_effect=write), + ): + result = run( + workspace, [stage(workspace, "a", resources=["checkpoint-fixture"])] + ) + assert result["status"] == "passed" + assert len(probes) >= 2 and len(persisted) == 1 + + +def test_checkpoint_save_failure_never_releases_dependent_execution(workspace): + args, _, _ = workspace + original_write, original_command, original_wait = ( + runner.atomic_json, + runner._execute_stage_command, + runner.wait, + ) + scheduler_waiting = threading.Event() + checkpoint_stalled = threading.Event() + scheduler_rechecked = threading.Event() + release_failure = threading.Event() + failed_write_paths, results, errors = [], [], [] + + def command(selected, *values, **kwargs): + if selected["id"] == "producer": + # Let the scheduler finish its initial running-state save and enter + # its wait loop before the command publishes a passing checkpoint. + assert scheduler_waiting.wait(timeout=5) + return original_command(selected, *values, **kwargs) + + def wait(*values, **kwargs): + scheduler_waiting.set() + result = original_wait(*values, **kwargs) + if checkpoint_stalled.is_set(): + scheduler_rechecked.set() + return result + + def write(path, payload): + producer = next( + (item for item in payload.get("stages", []) if item["id"] == "producer"), + None, + ) + if ( + producer + and producer.get("checkpoint_verified") is True + and not failed_write_paths + ): + failed_write_paths.append(path) + checkpoint_stalled.set() + assert release_failure.wait(timeout=5) + raise OSError("fixture checkpoint persistence failed") + return original_write(path, payload) + + def execute(): + try: + results.append( + run( + workspace, + [ + stage(workspace, "producer"), + stage(workspace, "dependent", "beta", deps=["producer"]), + ], + ) + ) + except BaseException as exc: + errors.append(exc) + + worker = threading.Thread(target=execute) + with ( + patch.object(runner, "atomic_json", side_effect=write), + patch.object(runner, "_execute_stage_command", side_effect=command), + patch.object(runner, "wait", side_effect=wait), + ): + worker.start() + try: + assert checkpoint_stalled.wait(timeout=5) + assert scheduler_rechecked.wait(timeout=5) + # Keep persistence blocked across a scheduling turn: the in-memory + # producer status must not grant authority to start a consumer. + time.sleep(0.15) + assert count(workspace, "dependent") == 0 + durable = read_json(failed_write_paths[0]) + assert ( + stages_by_id(durable)["producer"].get("checkpoint_verified") is not True + ) + finally: + release_failure.set() + worker.join(timeout=8) + assert not worker.is_alive() and not errors + assert len(results) == 1 and results[0]["status"] == "failed" + assert count(workspace, "producer") == 1 and count(workspace, "dependent") == 0 + by_id = stages_by_id(results[0]) + assert by_id["producer"]["status"] == "failed" + assert by_id["producer"]["checkpoint_verified"] is False + assert "fixture checkpoint persistence failed" in by_id["producer"]["error"] + assert by_id["dependent"]["status"] == "skipped" + persisted = runner.read_receipt( + args.workspace_root, args.state_dir, results[0]["run_id"] + ) + assert stages_by_id(persisted)["producer"]["checkpoint_verified"] is False + + +def test_verified_checkpoint_reuses_unchanged_stage_after_other_repo_changes(workspace): + args, repos, _ = workspace + plan = [stage(workspace, "a"), stage(workspace, "b", "beta")] + first = run(workspace, plan) + old_bytes = Path(first["receipt_path"]).read_bytes() + (repos["beta"] / "source.txt").write_text("beta changed\n") + args.resume = first["run_id"] + second = run(workspace, plan) + by_id = stages_by_id(second) + assert second["status"] == "passed" and second["snapshot_verified"] is True + assert count(workspace, "a") == 1 and count(workspace, "b") == 2 + assert by_id["a"]["reused_from"] == first["run_id"] + assert "reused_from" not in by_id["b"] + assert by_id["a"]["checkpoint_verified"] is True + assert by_id["a"]["checkpoint_version"] == 1 + assert isinstance(by_id["a"]["cache_key"], str) and by_id["a"]["cache_key"] + assert second["source_fingerprint"] != first["source_fingerprint"] + assert Path(first["receipt_path"]).read_bytes() == old_bytes + + +def test_failed_post_execution_probe_preserves_actual_log_without_certifying_it( + workspace, +): + from govoplan_devkit.checkpoints import Checkpoints + + identity = Checkpoints.identity + calls = 0 + + def probe(self, selected): + nonlocal calls + calls += 1 + if calls == 2: + raise ValueError("fixture input became unreadable") + return identity(self, selected) + + with patch.object(Checkpoints, "identity", probe): + result = run(workspace, [stage(workspace, "a")]) + selected = result["stages"][0] + assert result["status"] == "stale" + assert selected["status"] == "stale" and selected["exit_code"] == 0 + assert selected["checkpoint_verified"] is False + assert Path(selected["log_path"]).read_text() == "a\n" + assert "fixture input became unreadable" in selected["error"] + + +def test_changed_and_new_commands_run_without_discarding_unrelated_checkpoint( + workspace, +): + args, _, _ = workspace + first = run(workspace, [stage(workspace, "a"), stage(workspace, "b", "beta")]) + args.resume = first["run_id"] + second = run( + workspace, + [ + stage(workspace, "a"), + stage(workspace, "b", "beta", body="print('changed command')"), + stage(workspace, "new", "beta"), + ], + ) + assert second["status"] == "passed" + assert count(workspace, "a") == 1 + assert count(workspace, "b") == 2 + assert count(workspace, "new") == 1 + assert first["plan_fingerprint"] != second["plan_fingerprint"] + + +def test_unrelated_profile_edit_does_not_change_existing_stage_execution_identity( + workspace, +): + args, _, _ = workspace + plan = [stage(workspace, "a")] + first = run(workspace, plan) + config = json.loads(args.project.read_text()) + config["checks"] = [ + { + "id": "extra", + "argv": [sys.executable, "-c", "print('extra')"], + "cwd": "beta", + "repos": ["beta"], + } + ] + config["profiles"] = {"backend": ["extra"]} + args.project.write_text(json.dumps(config)) + args.resume = first["run_id"] + second = run(workspace, plan) + assert second["status"] == "passed" + assert count(workspace, "a") == 1 + + +def test_added_dependency_edge_invalidates_consumer_even_when_both_repo_bytes_match( + workspace, +): + args, _, _ = workspace + a, b = stage(workspace, "a"), stage(workspace, "b", "beta") + first = run(workspace, [a, b]) + args.resume = first["run_id"] + second = run(workspace, [a, {**b, "deps": ["a"]}]) + assert second["status"] == "passed" + assert count(workspace, "a") == 1 and count(workspace, "b") == 2 + + +def test_failed_run_reuses_successful_phase_but_retries_failed_phase(workspace): + args, _, traces = workspace + ready = traces / "ready" + plan = [ + stage(workspace, "a"), + stage( + workspace, + "b", + "beta", + after=["a"], + body=f"raise SystemExit(0 if Path({str(ready)!r}).exists() else 3)", + ), + ] + first = run(workspace, plan) + assert first["status"] == "failed" + assert stages_by_id(first)["a"]["checkpoint_verified"] is True + ready.touch() + args.resume = first["run_id"] + second = run(workspace, plan) + assert second["status"] == "passed" + assert count(workspace, "a") == 1 and count(workspace, "b") == 2 + + +def test_changed_dependency_invalidates_transitive_consumers_but_not_independent_stage( + workspace, +): + args, repos, _ = workspace + plan = [ + stage(workspace, "a"), + stage(workspace, "b", "beta", deps=["a"]), + stage(workspace, "c", "beta", deps=["b"]), + stage(workspace, "independent", "beta"), + ] + first = run(workspace, plan) + (repos["alpha"] / "source.txt").write_text("alpha changed\n") + args.resume = first["run_id"] + second = run(workspace, plan) + assert second["status"] == "passed" + assert { + identity: count(workspace, identity) + for identity in ("a", "b", "c", "independent") + } == {"a": 2, "b": 2, "c": 2, "independent": 1} + + +def test_order_only_predecessor_change_does_not_invalidate_independent_stage(workspace): + args, repos, _ = workspace + plan = [stage(workspace, "a"), stage(workspace, "b", "beta", after=["a"])] + first = run(workspace, plan) + (repos["alpha"] / "source.txt").write_text("alpha changed\n") + args.resume = first["run_id"] + second = run(workspace, plan) + assert second["status"] == "passed" + assert count(workspace, "a") == 2 and count(workspace, "b") == 1 + assert stages_by_id(second)["b"]["reused_from"] == first["run_id"] + + +def test_order_only_failure_prevents_new_downstream_execution(workspace): + plan = [ + stage(workspace, "a", body="raise SystemExit(8)"), + stage(workspace, "b", "beta", after=["a"]), + ] + result = run(workspace, plan) + assert result["status"] == "failed" + assert count(workspace, "a") == 1 and count(workspace, "b") == 0 + assert stages_by_id(result)["b"]["status"] == "skipped" + + +def test_never_reused_stage_and_actual_consumers_rerun_but_order_only_stage_can_reuse( + workspace, +): + args, _, _ = workspace + plan = [ + stage(workspace, "producer", reuse="never"), + stage(workspace, "consumer", "beta", deps=["producer"]), + stage(workspace, "independent", "beta", after=["producer"]), + ] + first = run(workspace, plan) + args.resume = first["run_id"] + second = run(workspace, plan) + assert second["status"] == "passed" + assert count(workspace, "producer") == 2 + assert count(workspace, "consumer") == 2 + assert count(workspace, "independent") == 1 + + +def test_unspecified_input_scope_stays_conservatively_workspace_wide(workspace): + args, repos, _ = workspace + plan = [stage(workspace, "broad", "beta", inputs=False)] + first = run(workspace, plan) + (repos["alpha"] / "source.txt").write_text("alpha changed\n") + args.resume = first["run_id"] + second = run(workspace, plan) + assert second["status"] == "passed" + assert count(workspace, "broad") == 2 + + +def test_global_environment_change_invalidates_all_scoped_checkpoints(workspace): + args, _, _ = workspace + plan = [stage(workspace, "a"), stage(workspace, "b", "beta")] + first = run(workspace, plan) + args.resume = first["run_id"] + second = run(workspace, plan, environment="f" * 64) + assert second["status"] == "passed" + assert count(workspace, "a") == 2 and count(workspace, "b") == 2 + assert second["environment_fingerprint"] != first["environment_fingerprint"] + + +def test_changed_declared_scope_prevents_same_id_reuse(workspace): + args, _, _ = workspace + selected = stage(workspace, "a") + first = run(workspace, [selected]) + args.resume = first["run_id"] + selected = {**selected, "inputs": {"repos": ["alpha", "beta"]}} + second = run(workspace, [selected]) + assert second["status"] == "passed" and count(workspace, "a") == 2 + + +def test_input_mutation_during_passing_command_never_creates_reusable_checkpoint( + workspace, +): + args, repos, _ = workspace + plan = [ + stage( + workspace, + "mutates", + body="Path('source.txt').write_text('mutated during stage\\n')", + ) + ] + first = run(workspace, plan) + assert first["status"] != "passed" + assert stages_by_id(first)["mutates"].get("checkpoint_verified") is not True + args.resume = first["run_id"] + # The same command is now stable against the already-mutated current bytes; + # its unverified first result still cannot be skipped. + second = run(workspace, plan) + assert count(workspace, "mutates") == 2 + assert second["status"] == "passed" + assert (repos["alpha"] / "source.txt").read_text() == "mutated during stage\n" + + +def test_later_restoration_of_bytes_cannot_turn_invalid_phase_into_overall_pass( + workspace, +): + _, repos, _ = workspace + source = repos["alpha"] / "source.txt" + original = source.read_text() + plan = [ + stage( + workspace, + "mutates", + body="Path('source.txt').write_text('temporary change\\n')", + ), + stage( + workspace, + "restores", + "beta", + after=["mutates"], + body=f"Path({str(source)!r}).write_text({original!r})", + ), + ] + result = run(workspace, plan) + assert result["status"] != "passed" + assert stages_by_id(result)["mutates"].get("checkpoint_verified") is not True + + +def test_recovered_interruption_reuses_only_verified_durable_checkpoint(workspace): + args, _, _ = workspace + plan = [stage(workspace, "a"), stage(workspace, "b", "beta", after=["a"])] + first = run(workspace, plan) + receipt = read_json(Path(first["receipt_path"])) + receipt.update( + status="running", phase="checking", snapshot_verified=False, finished_at=None + ) + unfinished = stages_by_id(receipt)["b"] + unfinished.update(status="running", exit_code=None, checkpoint_verified=False) + atomic_json(Path(first["receipt_path"]), runner._seal(receipt)) + parser = argparse.ArgumentParser() + runner.register(parser.add_subparsers(dest="command", required=True)) + recovered = parser.parse_args( + ["recover", first["run_id"], "--apply", "--confirm-processes-stopped"], + namespace=Namespace(**vars(args)), + ) + recovery = recovered.handler(recovered) + assert recovery["status"] == "interrupted" + args.resume = first["run_id"] + second = run(workspace, plan) + assert second["status"] == "passed" + assert count(workspace, "a") == 1 and count(workspace, "b") == 2 + assert stages_by_id(second)["a"]["reused_from"] == first["run_id"] + + +@pytest.mark.parametrize("change", ["tampered", "missing"]) +def test_cached_log_tamper_or_loss_is_not_accepted_as_verified_evidence( + workspace, change +): + args, _, _ = workspace + plan = [stage(workspace, "a")] + first = run(workspace, plan) + path = Path(first["stages"][0]["log_path"]) + if change == "missing": + path.unlink() + else: + path.write_text("tampered evidence\n") + args.resume = first["run_id"] + result = run(workspace, plan) + assert result["status"] == "failed" + assert ( + result["snapshot_verified"] is not True + or result["stages"][0]["status"] == "failed" + ) + assert result["stages"][0].get("checkpoint_verified") is not True + assert "reused_from" not in result["stages"][0] + assert "error" in result["stages"][0] + assert count(workspace, "a") == 1 + + +def test_stale_run_donates_only_checkpoints_matching_final_current_inputs(workspace): + args, repos, _ = workspace + source = repos["alpha"] / "source.txt" + plan = [ + stage(workspace, "a"), + stage( + workspace, + "changes-alpha", + "beta", + after=["a"], + body=f"Path({str(source)!r}).write_text('new alpha bytes\\n')", + ), + ] + first = run(workspace, plan) + assert first["status"] != "passed" + args.resume = first["run_id"] + second = run(workspace, plan) + assert second["status"] == "passed" + assert count(workspace, "a") == 2 + # This order-only phase did not consume alpha and its own scoped inputs are + # still identical; it may retain its independently verified checkpoint. + assert count(workspace, "changes-alpha") == 1 + + +def test_legacy_checkpointless_receipt_does_not_gain_incremental_authority(workspace): + args, repos, _ = workspace + plan = [stage(workspace, "a")] + first = run(workspace, plan) + receipt = read_json(Path(first["receipt_path"])) + receipt.pop("fingerprint_version", None) + for item in receipt["stages"]: + for field in ("checkpoint_version", "checkpoint_verified", "cache_key"): + item.pop(field, None) + atomic_json(Path(first["receipt_path"]), runner._seal(receipt)) + (repos["beta"] / "source.txt").write_text("unrelated changed source\n") + args.resume = first["run_id"] + try: + second = run(workspace, plan) + except ValueError: + return # Rejecting legacy incremental reuse is also safely fail-closed. + assert second["status"] == "passed" + assert count(workspace, "a") == 2 diff --git a/tests/test_devkit_inputs.py b/tests/test_devkit_inputs.py new file mode 100755 index 0000000..0bb5dde --- /dev/null +++ b/tests/test_devkit_inputs.py @@ -0,0 +1,393 @@ +"""Repository input identities use disposable Git fixtures, never remote effects.""" + +from copy import deepcopy +import os +from pathlib import Path +import subprocess +import sys + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools/devkit")) +from govoplan_devkit import inputs +from govoplan_devkit.inputs import InputSnapshotter, validate_input_declaration +from govoplan_devkit.workspace import Project, Repository, source_fingerprint + + +def git(repo, *arguments): + return subprocess.run( + ["git", "-C", str(repo), *arguments], capture_output=True, check=True + ).stdout + + +@pytest.fixture +def fixture(tmp_path, monkeypatch): + repositories = [] + for name in ("alpha", "beta"): + path = tmp_path / name + path.mkdir() + git(path, "init", "-q") + (path / "source.txt").write_text("initial\n") + git(path, "add", "source.txt") + git( + path, + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "commit", + "-qm", + "fixture", + ) + repositories.append(Repository(name, path, ("alias-" + name,))) + project = Project( + "Fixture", + tuple(repositories), + {"tools": {}, "profiles": {"quick": ["one"]}, "checks": []}, + ) + # Avoid incidental edits by other agents affecting these source-input tests. + # The actual tool-source hashing implementation is checked separately. + monkeypatch.setattr( + InputSnapshotter, "_tooling_identity", lambda *_: "fixture-devkit-source" + ) + return tmp_path, project, InputSnapshotter(project, workspace_root=tmp_path) + + +def stage(identity="one", repos=None, **extra): + return { + "id": identity, + "argv": ["true"], + **({"inputs": {"repos": repos}} if repos is not None else {}), + **extra, + } + + +def fingerprint(snapshot, name="one"): + return snapshot["stages"][name]["fingerprint"] + + +def test_undeclared_inputs_remain_whole_workspace(fixture): + root, _, engine = fixture + result = engine.snapshot([stage()]) + assert result["complete_workspace"] is True + assert result["observed_scope"]["repos"] == ["alpha", "beta"] + assert result["stages"]["one"]["scope"] == { + "version": 1, + "kind": "workspace", + "declared": False, + "repos": ["alpha", "beta"], + } + (root / "beta/source.txt").write_text("changed\n") + assert fingerprint(engine.snapshot([stage()])) != fingerprint(result) + + +def test_scoped_input_does_not_read_unrelated_repository_bytes(fixture, monkeypatch): + root, _, engine = fixture + original = inputs.os.open + + def guarded(path, *args, **kwargs): + if Path(path).is_relative_to(root / "beta"): + raise AssertionError("Unrelated repository input was opened") + return original(path, *args, **kwargs) + + monkeypatch.setattr(inputs.os, "open", guarded) + first = engine.snapshot([stage(repos=["alpha"])]) + (root / "beta/source.txt").write_text("unrelated change") + second = engine.snapshot([stage(repos=["alpha"])]) + assert fingerprint(first) == fingerprint(second) + assert second["scan_stats"]["repositories"] == 1 + assert second["scan_stats"]["git_calls"] == 2 + assert second["complete_workspace"] is False + + +def test_repository_union_scans_once_per_snapshot(fixture): + _, _, engine = fixture + result = engine.snapshot( + [ + stage("one", ["alpha"]), + stage("two", ["alpha"]), + stage("three", ["beta", "alpha"]), + ] + ) + assert result["scan_stats"]["repositories"] == 2 + assert result["scan_stats"]["git_calls"] == 4 + assert result["stages"]["three"]["scope"]["repos"] == ["alpha", "beta"] + + +def test_session_rechecks_metadata_but_reuses_stable_file_content(fixture): + _, _, engine = fixture + plan = [stage(repos=["alpha"])] + before = engine.snapshot(plan) + after = engine.snapshot(plan) + assert fingerprint(before) == fingerprint(after) + assert before["scan_stats"]["bytes"] == len("initial\n") + assert after["scan_stats"]["bytes"] == 0 + assert after["scan_stats"]["cache_hits"] == 1 + assert after["scan_stats"]["git_calls"] == 2 + + +def test_changed_bytes_with_restored_mtime_are_not_reused(fixture): + root, _, engine = fixture + plan = [stage(repos=["alpha"])] + before = engine.snapshot(plan) + path = root / "alpha/source.txt" + metadata = path.stat() + path.write_text("changed\n") + os.utime(path, ns=(metadata.st_atime_ns, metadata.st_mtime_ns)) + after = engine.snapshot(plan) + assert fingerprint(after) != fingerprint(before) + assert after["scan_stats"]["bytes"] == len("changed\n") + + +def test_inode_replacement_with_same_size_and_mtime_is_not_reused(fixture): + root, _, engine = fixture + plan = [stage(repos=["alpha"])] + before = engine.snapshot(plan) + path = root / "alpha/source.txt" + metadata = path.stat() + replacement = root / "replacement.txt" + replacement.write_text("changed\n") + os.utime(replacement, ns=(metadata.st_atime_ns, metadata.st_mtime_ns)) + replacement.replace(path) + after = engine.snapshot(plan) + assert fingerprint(before) != fingerprint(after) + assert after["scan_stats"]["cache_hits"] == 0 + + +@pytest.mark.parametrize("flag", ["assume-unchanged", "skip-worktree"]) +def test_hidden_index_flags_do_not_hide_worktree_changes(fixture, flag): + root, _, engine = fixture + git(root / "alpha", "update-index", "--" + flag, "source.txt") + before = engine.source_snapshot(["alpha"]) + (root / "alpha/source.txt").write_text("hidden change\n") + after = engine.source_snapshot(["alpha"]) + assert before["observed_source_fingerprint"] != after["observed_source_fingerprint"] + + +def test_index_head_and_new_deleted_files_are_bound(fixture): + root, _, engine = fixture + repo = root / "alpha" + identities = [] + + def record(): + identities.append( + engine.source_snapshot(["alpha"])["observed_source_fingerprint"] + ) + + record() + (repo / "source.txt").write_text("changed\n") + record() + git(repo, "add", "source.txt") + record() + git( + repo, + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "commit", + "-qm", + "change", + ) + record() + (repo / "new.txt").write_text("new") + record() + (repo / "source.txt").unlink() + record() + assert len(set(identities)) == len(identities) + + +def test_missing_registered_repository_becoming_present_invalidates(fixture): + root, project, _ = fixture + extra = Repository("missing", root / "missing") + extended = Project("Fixture", (*project.repositories, extra), project.config) + engine = InputSnapshotter(extended, workspace_root=root) + before = engine.source_snapshot(["missing"]) + extra.path.mkdir() + git(extra.path, "init", "-q") + after = engine.source_snapshot(["missing"]) + assert before["observed_source_fingerprint"] != after["observed_source_fingerprint"] + + +def test_unrelated_plan_config_edits_do_not_invalidate_scoped_stage(fixture): + root, project, engine = fixture + plan = [stage(repos=["alpha"])] + before = engine.snapshot(plan) + updated = deepcopy(project.config) + updated["profiles"]["full"] = ["unrelated"] + updated["checks"].append({"id": "unrelated", "argv": ["false"]}) + other = InputSnapshotter( + Project("Changed label", project.repositories, updated), workspace_root=root + ) + assert fingerprint(other.snapshot(plan)) == fingerprint(before) + + +def test_scope_command_tools_and_tooling_change_invalidate(fixture): + root, project, engine = fixture + first = engine.snapshot([stage(repos=["alpha"])]) + assert fingerprint(engine.snapshot([stage(repos=["beta"])])) != fingerprint(first) + assert fingerprint( + engine.snapshot([stage(repos=["alpha"], argv=["false"])]) + ) != fingerprint(first) + assert fingerprint( + engine.snapshot([stage(repos=["alpha"])], tooling_fingerprint="new-env") + ) != fingerprint(first) + changed = deepcopy(project.config) + changed["tools"] = {"node": "/another/node"} + other = InputSnapshotter( + Project("Fixture", project.repositories, changed), workspace_root=root + ) + assert fingerprint(other.snapshot([stage(repos=["alpha"])])) != fingerprint(first) + + +def test_source_only_attestation_is_independent_of_plan_and_tooling(fixture): + _, _, engine = fixture + one = engine.snapshot([stage(repos=["alpha"])], tooling_fingerprint="env-one") + two = engine.snapshot( + [stage("different", ["alpha"], argv=["false"])], tooling_fingerprint="env-two" + ) + source = engine.source_snapshot(["alpha"]) + assert ( + one["observed_source_fingerprint"] + == two["observed_source_fingerprint"] + == source["observed_source_fingerprint"] + ) + assert ( + one["stages"]["one"]["source_fingerprint"] + == source["observed_source_fingerprint"] + ) + assert source["fingerprint_version"] == inputs.FINGERPRINT_VERSION + + +@pytest.mark.parametrize( + "value", + [ + None, + {}, + {"paths": ["src/**"]}, + {"repos": []}, + {"repos": "alpha"}, + {"repos": ["unknown"]}, + {"repos": ["alias-alpha"]}, + {"repos": ["alpha", "alpha"]}, + {"repos": [""]}, + {"repos": ["alpha"], "extra": True}, + ], +) +def test_synthetic_stage_scopes_fail_closed(fixture, value): + _, _, engine = fixture + with pytest.raises(ValueError): + engine.snapshot([stage(inputs=value)]) + + +def test_duplicate_stage_ids_and_escaped_repository_paths_fail(fixture): + root, project, engine = fixture + with pytest.raises(ValueError, match="Duplicate"): + engine.snapshot([stage(), stage()]) + bad = Project("Bad", (Repository("outside", root.parent),), project.config) + with pytest.raises(ValueError, match="escapes"): + InputSnapshotter(bad, workspace_root=root) + + +def test_declaration_validation_is_pure_before_dry_run(monkeypatch): + monkeypatch.setattr( + inputs, + "git_bytes", + lambda *_a, **_k: pytest.fail("Planning validation ran Git"), + ) + assert validate_input_declaration( + {"repos": ["beta", "alpha"]}, {"alpha", "beta"} + ) == {"repos": ["alpha", "beta"]} + with pytest.raises(ValueError): + validate_input_declaration({"repos": ["unknown"]}, {"alpha", "beta"}) + + +def test_runtime_records_are_not_hashed_as_execution_plans(fixture): + _, _, engine = fixture + with pytest.raises(ValueError, match="freshly planned"): + engine.snapshot([stage(repos=["alpha"], status="passed")]) + + +def test_membership_count_is_bounded_before_entry_hashing(fixture, monkeypatch): + root, _, engine = fixture + (root / "alpha/new.txt").write_text("extra") + monkeypatch.setattr(inputs, "MAX_REPOSITORY_ENTRIES", 1) + with pytest.raises(ValueError, match="entry count"): + engine.source_snapshot(["alpha"]) + + +def test_symlink_to_ignored_file_inside_repository_binds_target(fixture): + root, _, engine = fixture + repo = root / "alpha" + (repo / ".gitignore").write_text("ignored.txt\n") + (repo / "ignored.txt").write_text("first") + (repo / "linked.txt").symlink_to("ignored.txt") + before = engine.source_snapshot(["alpha"]) + (repo / "ignored.txt").write_text("other") + assert ( + engine.source_snapshot(["alpha"])["observed_source_fingerprint"] + != before["observed_source_fingerprint"] + ) + + +def test_dangling_symlink_target_creation_is_observed(fixture): + root, _, engine = fixture + repo = root / "alpha" + (repo / "linked.txt").symlink_to("future.txt") + before = engine.source_snapshot(["alpha"]) + (repo / "future.txt").write_text("created") + assert ( + engine.source_snapshot(["alpha"])["observed_source_fingerprint"] + != before["observed_source_fingerprint"] + ) + + +def test_cross_repository_and_directory_symlinks_are_not_silently_reused(fixture): + root, _, engine = fixture + link = root / "alpha/linked.txt" + link.symlink_to(root / "beta/source.txt") + with pytest.raises(ValueError, match="escapes"): + engine.source_snapshot(["alpha"]) + link.unlink() + link.symlink_to(".") + with pytest.raises(ValueError, match="regular file"): + engine.source_snapshot(["alpha"]) + + +def test_file_change_during_hash_is_rejected(fixture, monkeypatch): + root, _, engine = fixture + path = root / "alpha/source.txt" + original = inputs.os.fstat + changed = False + + def mutate(descriptor): + nonlocal changed + metadata = original(descriptor) + if not changed: + changed = True + path.write_text("changed during read") + return metadata + + monkeypatch.setattr(inputs.os, "fstat", mutate) + with pytest.raises(ValueError, match="changed"): + engine._file_hash(path, inputs._stats()) + + +def test_legacy_whole_project_api_remains_independent(fixture): + _, project, engine = fixture + legacy = source_fingerprint(project) + engine.snapshot([stage(repos=["alpha"])]) + assert source_fingerprint(project) == legacy + + +def test_real_tooling_inventory_is_bound_and_memoized(tmp_path): + repo = Repository("example", tmp_path) + project = Project("Fixture", (repo,), {"tools": {}}) + engine = InputSnapshotter(project, workspace_root=tmp_path) + before_stats, after_stats = inputs._stats(), inputs._stats() + before = engine._tooling_identity(before_stats) + after = engine._tooling_identity(after_stats) + assert before == after + assert before_stats["tooling_files"] > 15 and before_stats["tooling_bytes"] > 0 + assert after_stats["tooling_files"] == after_stats["tooling_cache_hits"] + assert after_stats["tooling_bytes"] == 0 diff --git a/tests/test_devkit_issues.py b/tests/test_devkit_issues.py new file mode 100755 index 0000000..508389d --- /dev/null +++ b/tests/test_devkit_issues.py @@ -0,0 +1,387 @@ +import argparse +from copy import deepcopy +import json +from pathlib import Path +import subprocess +import sys + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools/devkit")) +from govoplan_devkit import issues +from govoplan_devkit.common import atomic_json, digest, state_root +from govoplan_devkit.workspace import load_project, source_fingerprint + + +@pytest.fixture +def args(tmp_path, monkeypatch): + monkeypatch.delenv("GITEA_TOKEN", raising=False) + root = tmp_path / "repo" + root.mkdir() + subprocess.run(["git", "init", "-q", str(root)], check=True) + subprocess.run(["git", "-C", str(root), "remote", "add", "origin", "https://gitea.invalid/team/repo.git"], check=True) + project = tmp_path / "project.json" + project.write_text(json.dumps({"schema_version": 1, "name": "Fixture", "repositories": [{"name": "repo", "path": "repo"}]})) + env_file = tmp_path / "private.env" + env_file.write_text("GITEA_TOKEN=fixture-private-token\nGITEA_OWNER=wrong-owner\n") + return argparse.Namespace(workspace_root=tmp_path, state_dir=tmp_path / "state", project=project, + root=root, issue=7, target_plan=None, remote="origin", env_file=env_file, evidence=None, + key="verification", note_summary=["Verified scoped changes."], next_steps=["Manual review remains."], + body_file=None, note_file=None, apply=False, retry_uncertain=False) + + +class FakeClient: + def __init__(self, target): + self.target = target + self.calls = [] + self.comments = [] + self.issue_id = 700 + self.post_mode = "normal" + self.bad_binding = False + self.repeat_pages = False + self.bad_issue = False + self.fail = False + self.page_size = 2 # A server may enforce a cap smaller than the requested limit. + + def close(self): + pass + + def request_json(self, method, path, body=None, query=None): + self.calls.append((method, path, deepcopy(body), deepcopy(query))) + if self.fail: + raise RuntimeError("Remote echoed fixture-private-token") + if method == "GET" and path == self.target.path: + return {"id": self.issue_id, "number": self.target.issue, "html_url": self.target.url + ("wrong" if self.bad_issue else ""), + "state": "closed", "body": "- [ ] Preserve this checklist"} + if method == "GET" and path == self.target.path + "/comments": + page = 1 if self.repeat_pages else query["page"] + return deepcopy(self.comments[(page - 1) * self.page_size:page * self.page_size]) + if method == "GET" and "/issues/comments/" in path: + comment = deepcopy(next(item for item in self.comments if str(item["id"]) == path.rsplit("/", 1)[1])) + comment["html_url"] = self.target.url + ("-other" if self.bad_binding else "") + "#issuecomment-" + str(comment["id"]) + return comment + if method == "POST" and path == self.target.path + "/comments": + if self.post_mode == "timeout-before-commit": + raise TimeoutError("fixture-private-token") + comment = {"id": 1000 + len(self.comments), "body": body["body"]} + self.comments.append(comment) + if self.post_mode == "timeout-after-commit": + raise TimeoutError("fixture-private-token") + return deepcopy(comment) + raise AssertionError((method, path)) + + @property + def posts(self): + return [call for call in self.calls if call[0] == "POST"] + + +@pytest.fixture +def client(args, monkeypatch): + client = FakeClient(issues.resolve_target(args.root, args.issue, args.workspace_root)) + monkeypatch.setattr(issues, "make_client", lambda target, token: client) + return client + + +def test_default_dry_run_is_offline_and_ignores_ambient_target_overrides(args, monkeypatch): + monkeypatch.setenv("GITEA_OWNER", "wrong") + monkeypatch.setenv("GITEA_REPO", "wrong") + monkeypatch.setenv("GITEA_URL", "https://wrong.invalid") + monkeypatch.setattr(issues, "make_client", lambda *_: pytest.fail("No network in preview")) + args.env_file = args.workspace_root / "does-not-exist.env" + result = issues.handle_note(args) + assert result["targets"][0]["url"] == "https://gitea.invalid/team/repo/issues/7" + assert result["targets"][0]["status"] == "would-post" + assert not args.state_dir.exists() + + +def test_scoped_checkpoint_evidence_compares_only_recorded_sources(args, monkeypatch): + from govoplan_devkit import runner + + other = args.workspace_root / "other" + other.mkdir() + subprocess.run(["git", "init", "-q", str(other)], check=True) + declaration = json.loads(args.project.read_text()) + declaration["repositories"].append({"name": "other", "path": "other"}) + args.project.write_text(json.dumps(declaration)) + monkeypatch.setattr(runner, "environment_fingerprint", lambda *_: "fixture-env") + run_args = argparse.Namespace(**{**vars(args), "jobs": 1, "profile": "quick", "dry_run": False}) + result = runner.run_checks(run_args, [{"id": "scoped", "argv": [sys.executable, "-c", "print('ok')"], + "cwd": str(args.root), "inputs": {"repos": ["repo"]}}]) + assert result["status"] == "passed" + evidence = issues.evidence_record(result["run_id"], args) + assert evidence["source_state"] == "matches-current" + assert evidence["source_scope"]["repos"] == ["repo"] + assert any("recorded repository input scope" in note for note in evidence["coverage_notes"]) + (other / "unrelated.txt").write_text("Outside the recorded scope") + assert issues.evidence_record(result["run_id"], args)["source_state"] == "matches-current" + (args.root / "changed.txt").write_text("Inside the recorded scope") + assert issues.evidence_record(result["run_id"], args)["source_state"] == "historical-source-differs" + + +def test_passing_aggregate_cannot_hide_skipped_checks(args): + payload = receipt(args) + payload["stages"].append({"id": "skipped", "status": "skipped", "exit_code": None}) + with pytest.raises(ValueError, match="inconsistent"): + issues.validate_receipt(payload, args.workspace_root) + + +def test_apply_is_append_only_read_back_and_idempotent(args, client): + args.apply = True + result = issues.handle_note(args) + assert result["targets"][0]["status"] == "posted-verified" + assert len(client.posts) == 1 + again = issues.handle_note(args) + assert again["targets"][0]["status"] == "existing-verified" + assert len(client.posts) == 1 + assert {call[0] for call in client.calls} == {"GET", "POST"} + assert all(call[1].endswith("/comments") for call in client.posts) + + +def test_complete_pagination_continues_past_short_pages(args, client): + body = issues.handle_note(args)["targets"][0]["body"] + client.comments = [{"id": n, "body": "unrelated"} for n in range(1, 6)] + [{"id": 6, "body": body}] + args.apply = True + result = issues.handle_note(args) + assert result["targets"][0]["status"] == "existing-verified" + assert not client.posts + assert max(call[3]["page"] for call in client.calls if call[3]) == 4 + + +@pytest.mark.parametrize("collision", ["different-body", "duplicate", "repeating-pagination"]) +def test_collisions_and_incomplete_pagination_refuse_post(args, client, collision): + body = issues.handle_note(args)["targets"][0]["body"] + client.comments = [{"id": 1, "body": body}] + if collision == "different-body": + client.comments[0]["body"] += "changed" + elif collision == "duplicate": + client.comments.append({"id": 2, "body": body}) + else: + client.repeat_pages = True + args.apply = True + assert issues.handle_note(args)["_exit_code"] == 2 + assert not client.posts + + +def test_timeout_after_commit_reconciles_without_replaying_post(args, client): + args.apply = True + client.post_mode = "timeout-after-commit" + result = issues.handle_note(args) + assert result["targets"][0]["status"] == "reconciled-verified" + assert len(client.posts) == 1 + assert "fixture-private-token" not in json.dumps(result) + + +def test_uncertain_post_requires_explicit_retry_after_reconciliation(args, client): + args.apply = True + client.post_mode = "timeout-before-commit" + assert issues.handle_note(args)["targets"][0]["status"] == "uncertain" + client.post_mode = "normal" + assert issues.handle_note(args)["targets"][0]["status"] == "uncertain-retry-required" + assert len(client.posts) == 1 + args.retry_uncertain = True + assert issues.handle_note(args)["targets"][0]["status"] == "posted-verified" + assert len(client.posts) == 2 + journals = list(state_root(args.workspace_root, args.state_dir).glob("issue-notes/*.json")) + assert journals and all("fixture-private-token" not in path.read_text() for path in journals) + + +def test_uncertain_readback_is_not_reported_verified_and_later_reconciles(args, client): + args.apply = True + client.bad_binding = True + assert issues.handle_note(args)["targets"][0]["status"] == "uncertain" + client.bad_binding = False + assert issues.handle_note(args)["targets"][0]["status"] == "existing-verified" + assert len(client.posts) == 1 + + +def test_journal_binds_immutable_issue_id(args, client): + args.apply = True + assert issues.handle_note(args)["targets"][0]["status"] == "posted-verified" + client.issue_id += 1 + client.comments.clear() + args.retry_uncertain = True + assert issues.handle_note(args)["_exit_code"] == 2 + assert len(client.posts) == 1 + + +def _plan(args, rows): + args.root = args.issue = None + args.target_plan = args.workspace_root / "targets.json" + args.target_plan.write_text(json.dumps({"schema_version": 1, "targets": rows})) + + +@pytest.mark.parametrize("change", ["wrong-url", "duplicate", "outside", "mixed-origin", "credentials"]) +def test_target_plans_require_exact_unique_workspace_bindings(args, change): + row = {"root": "repo", "issue": 7, "url": "https://gitea.invalid/team/repo/issues/7"} + if change == "wrong-url": + row["url"] = "https://gitea.invalid/team/other/issues/7" + elif change == "outside": + row["root"] = "../other" + elif change == "credentials": + subprocess.run(["git", "-C", str(args.root), "remote", "set-url", "origin", "https://user:private@gitea.invalid/team/repo.git"], check=True) + rows = [row, deepcopy(row)] if change == "duplicate" else [row] + if change == "mixed-origin": + other = args.workspace_root / "other" + other.mkdir() + subprocess.run(["git", "init", "-q", str(other)], check=True) + subprocess.run(["git", "-C", str(other), "remote", "add", "origin", "http://gitea.invalid/team/other.git"], check=True) + rows.append({"root": "other", "issue": 8, "url": "http://gitea.invalid/team/other/issues/8"}) + _plan(args, rows) + with pytest.raises(ValueError): + issues.handle_note(args) + + +def test_all_targets_preflight_before_first_post_and_posts_are_serial(args, client, monkeypatch): + first = client.target.record() + second = {**first, "issue": 8, "url": first["url"].rsplit("/", 1)[0] + "/8"} + _plan(args, [first, second]) + second_client = FakeClient(issues.resolve_target(Path(first["root"]), 8, args.workspace_root)) + second_client.bad_issue = True + monkeypatch.setattr(issues, "make_client", lambda target, token: client if target.issue == 7 else second_client) + args.apply = True + assert issues.handle_note(args)["_exit_code"] == 2 + assert not client.posts and not second_client.posts + second_client.bad_issue = False + result = issues.handle_note(args) + assert [row["status"] for row in result["targets"]] == ["posted-verified", "posted-verified"] + assert len(client.posts) == len(second_client.posts) == 1 + + +def test_credentials_and_response_errors_are_not_returned(args, client): + args.apply = True + client.fail = True + result = issues.handle_note(args) + assert result["_exit_code"] == 2 + assert "fixture-private-token" not in json.dumps(result) + args.note_summary = ["fixture-private-token"] + with pytest.raises(ValueError, match="credential"): + issues.handle_note(args) + + +def test_marker_injection_and_symlink_inputs_are_rejected(args): + args.note_summary = [issues.MARKER_PREFIX + "fake -->"] + with pytest.raises(ValueError, match="reserved"): + issues.handle_note(args) + args.note_summary = ["safe"] + args.body_file = args.workspace_root / "alias.md" + args.body_file.symlink_to(args.env_file) + with pytest.raises(ValueError, match="symlink"): + issues.handle_note(args) + + +@pytest.mark.parametrize("remote", ["https://gitea.invalid/team/repo.git?token=secret", "https://gitea.invalid/team/repo.git#other", "https://gitea.invalid:bad/team/repo.git"]) +def test_ambiguous_git_remote_is_not_silently_reinterpreted(args, remote): + subprocess.run(["git", "-C", str(args.root), "remote", "set-url", "origin", remote], check=True) + with pytest.raises(ValueError): + issues.handle_note(args) + + +def test_structured_inputs_are_merged_as_data_not_executed(args): + args.note_file = args.workspace_root / "note.json" + args.note_file.write_text(json.dumps({"summary": ["Recorded earlier"], "next": ["Still pending"], "body": "$(never-execute)"})) + args.body_file = args.workspace_root / "body.md" + args.body_file.write_text("`never-run-this-either`") + body = issues.handle_note(args)["targets"][0]["body"] + assert "Recorded earlier" in body and "Verified scoped changes" in body + assert "$(never-execute)" in body and "`never-run-this-either`" in body + + +def receipt(args): + return {"schema_version": 1, "run_id": "fixture-run", "workspace_root": str(args.workspace_root), + "project_file": str(args.project), "source_fingerprint": source_fingerprint(load_project(args.workspace_root, args.project)), + "status": "passed", "snapshot_verified": True, "generated_at": "2026-09-08T12:00:00Z", "finished_at": "2026-09-08T12:00:01Z", + "stages": [{"id": "fixture", "status": "passed", "exit_code": 0, "duration_seconds": 1, + "log_path": "/private/log-not-opened", "argv": ["never-execute-this"]}]} + + +def test_external_receipts_are_unverified_metadata_with_source_comparison(args): + path = args.workspace_root / "external.json" + path.write_text(json.dumps(receipt(args))) + args.evidence = str(path) + result = issues.handle_note(args) + evidence = result["evidence"] + assert evidence["origin"] == "external-unverified" + assert evidence["source_state"] == "matches-current" + assert "argv" not in evidence["stages"][0] + (args.root / "dirty.txt").write_text("changed") + assert issues.handle_note(args)["evidence"]["source_state"] == "historical-source-differs" + + +@pytest.mark.parametrize("mutation", ["foreign", "bad-fingerprint", "false-pass", "duplicate-stage", "unknown-status", "noninteger-exit", "invalid-state-shape", "invalid-stage-state-shape", "bool-schema"]) +def test_invalid_receipt_cannot_supply_evidence(args, mutation): + payload = receipt(args) + if mutation == "foreign": + payload["workspace_root"] = str(args.workspace_root.parent) + elif mutation == "bad-fingerprint": + payload["source_fingerprint"] = "claimed-green" + elif mutation == "false-pass": + payload["stages"][0]["exit_code"] = 1 + elif mutation == "duplicate-stage": + payload["stages"] *= 2 + elif mutation == "unknown-status": + payload["status"] = "complete-review" + elif mutation == "invalid-state-shape": + payload["status"] = {} + elif mutation == "invalid-stage-state-shape": + payload["stages"][0]["status"] = [] + elif mutation == "bool-schema": + payload["schema_version"] = True + else: + payload["stages"][0]["exit_code"] = False + with pytest.raises(ValueError): + issues.validate_receipt(payload, args.workspace_root) + + +def test_local_receipt_integrity_is_checked_but_not_an_attestation(args): + payload = receipt(args) + payload["integrity_sha256"] = digest(payload) + path = state_root(args.workspace_root, args.state_dir) / "runs/fixture-run/receipt.json" + atomic_json(path, payload) + args.evidence = "fixture-run" + result = issues.handle_note(args) + assert result["evidence"]["origin"] == "local-integrity-checked" + assert "not an independent attestation" in result["evidence"]["attestation"] + payload["status"] = "failed" + atomic_json(path, payload) + with pytest.raises(ValueError, match="integrity"): + issues.handle_note(args) + + +def test_timed_out_stage_is_reportable_without_claiming_success(args): + payload = receipt(args) + payload["status"] = "failed" + payload["stages"][0].update(status="timed_out", exit_code=-15) + assert issues.validate_receipt(payload, args.workspace_root)["status"] == "failed" + + +def test_scoped_coverage_limits_are_preserved_in_evidence_and_rendered_note(args, monkeypatch): + payload = receipt(args) + monkeypatch.setenv("FIXTURE_SECRET", "fixture-secret-value") + payload["stages"][0]["coverage_notes"] = ["Compiler/chained suite not run.", "Manual fixture-secret-value"] + path = args.workspace_root / "coverage.json" + path.write_text(json.dumps(payload)) + args.evidence = str(path) + result = issues.handle_note(args) + evidence = result["evidence"] + assert evidence["coverage_notes"] == evidence["stages"][0]["coverage_notes"] + assert "Compiler/chained suite not run." in result["targets"][0]["body"] + assert "omitted checks ran" in result["targets"][0]["body"] + assert "